Files
arrr-erp/app/modules/accounting/ui.py
T
2026-09-06 20:49:42 +05:30

1006 lines
44 KiB
Python

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, 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
from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.clients.models import Client
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.core.rbac.permission_guard import require_permission
from app.modules.documents.services import build_document_scope, get_active_storage_node_for_branch, client_folder_parts, sanitize_segment
from app.modules.accounting.agent_bridge import request_agent_command
from app.modules.registrations.models import ClientRegistration, RegistrationType
router = APIRouter(prefix="/tools/tally", tags=["accounting-tally-ui"])
def _denied():
from app.core.http_responses import ui_access_denied
return ui_access_denied()
def _require_partner(request: Request, db, permission: str):
user = get_current_user(request, db)
if not user:
return None, RedirectResponse(url="/login", status_code=303)
roles = set(get_user_roles(db, user.id))
if "Partner" not in roles:
return None, _denied()
try:
require_permission(db, user, permission)
except Exception:
return None, _denied()
return user, None
def _visible_clients(db, request: Request, user):
scope = build_document_scope(request, db, user)
stmt = select(Client).where(Client.tenant_id == scope.tenant_id, Client.partner_id == user.id)
if scope.branch_id is not None:
stmt = stmt.where(Client.branch_id == scope.branch_id)
return db.execute(stmt.order_by(Client.client_name.asc(), Client.id.asc())).scalars().all(), scope
def _find_visible_client(db, request: Request, user, client_id: int):
clients, scope = _visible_clients(db, request, user)
client = next((row for row in clients if int(row.id) == int(client_id)), None)
return client, clients, scope
def _financial_year_for_date(value: date | None = None) -> str:
value = value or date.today()
start_year = value.year if value.month >= 4 else value.year - 1
return f"{start_year}-{str(start_year + 1)[-2:]}"
def _accounting_storage_payload(client, financial_year: str | None = None) -> dict:
"""Use the same FY + client-folder policy as existing Engagement storage."""
fy_value = str(financial_year or _financial_year_for_date()).strip()
fy_folder = sanitize_segment(f"FY{fy_value}", "FY")
letter, client_folder = client_folder_parts(client, int(client.id))
relative_dir = Path(fy_folder) / "Clients" / letter / client_folder / "Accounting"
return {
"client_id": int(client.id),
"client_name": str(client.client_name or "").strip(),
"client_code": str(getattr(client, "client_code", "") or "").strip(),
"financial_year": fy_value,
"accounting_relative_dir": relative_dir.as_posix(),
}
def _client_registrations(db, client, tenant_id: int):
rows = db.execute(
select(ClientRegistration, RegistrationType)
.join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id)
.where(
ClientRegistration.tenant_id == tenant_id,
ClientRegistration.client_id == client.id,
)
.order_by(RegistrationType.sort_order.asc(), ClientRegistration.id.asc())
).all()
return rows
def _registration_payload(registration, registration_type) -> dict:
return {
"id": int(registration.id),
"registration_type_id": int(registration.registration_type_id),
"registration_type_code": str(getattr(registration_type, "code", "") or "").strip(),
"registration_type_name": str(getattr(registration_type, "name", "") or "").strip(),
"registration_number": str(getattr(registration, "registration_number", "") or "").strip(),
"legal_name": str(getattr(registration, "legal_name", "") or "").strip(),
"trade_name": str(getattr(registration, "trade_name", "") or "").strip(),
"business_unit_id": getattr(registration, "business_unit_id", None),
"client_branch_id": getattr(registration, "client_branch_id", None),
}
def _normalise_gstin(value: str) -> str:
return re.sub(r"\s+", "", str(value or "").strip().upper())
def _erp_client_gstins(registrations) -> list[str]:
values: list[str] = []
for registration, registration_type in registrations or []:
if str(getattr(registration_type, "code", "") or "").strip().upper() != "GSTIN":
continue
number = _normalise_gstin(getattr(registration, "registration_number", ""))
if number and number not in values:
values.append(number)
return values
def _enrich_gstin_display(live_result: dict | None, registrations) -> None:
"""Attach ERP/Tally GSTIN display metadata without mutating stored mappings."""
if not isinstance(live_result, dict):
return
accounting = live_result.get("accounting") or {}
tally = live_result.get("tally") or {}
mappings = accounting.get("mappings") or []
companies = tally.get("companies") or []
client_gstins = _erp_client_gstins(registrations)
company_by_guid = {str(row.get("guid") or "").strip(): row for row in companies if isinstance(row, dict)}
for mapping in mappings:
if not isinstance(mapping, dict):
continue
registration_type = str(mapping.get("registration_type_code") or "").strip().upper()
registration_number = _normalise_gstin(mapping.get("registration_number"))
if mapping.get("registration_id") and registration_type == "GSTIN" and registration_number:
erp_gstins = [registration_number]
elif not mapping.get("registration_id"):
erp_gstins = list(client_gstins)
else:
erp_gstins = []
live_company = company_by_guid.get(str(mapping.get("tally_guid") or "").strip()) or {}
tally_gstins: list[str] = []
for value in list(live_company.get("gstins") or []) + [live_company.get("gstin"), mapping.get("gstin")]:
gstin = _normalise_gstin(value)
if gstin and gstin not in tally_gstins:
tally_gstins.append(gstin)
mapping["erp_gstins"] = erp_gstins
mapping["tally_gstins"] = tally_gstins
mapping["tally_gstin_display"] = ", ".join(tally_gstins)
if erp_gstins and tally_gstins:
mapping["gstin_match_status"] = "matched" if set(erp_gstins).intersection(tally_gstins) else "mismatch"
elif erp_gstins and not tally_gstins:
mapping["gstin_match_status"] = "tally_missing"
elif tally_gstins and not erp_gstins:
mapping["gstin_match_status"] = "erp_missing"
else:
mapping["gstin_match_status"] = "unavailable"
def _node_online(node) -> bool:
if not node or not node.last_seen_at_utc:
return False
seen = node.last_seen_at_utc
if seen.tzinfo is None:
seen = seen.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - seen).total_seconds() <= 180
def _render(request: Request, db, user, **context):
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(context)
return templates.TemplateResponse(
"modules/accounting/templates/accounting/tally.html",
base,
)
@router.get("")
def tally_tool(
request: Request,
client_id: int | None = None,
refresh: int = 0,
initialized: int = 0,
mapped: int = 0,
unmapped: int = 0,
synced: int = 0,
transactions_synced: int = 0,
export_started: int = 0,
error: str = "",
):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
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)
registrations = _client_registrations(db, selected_client, scope.tenant_id) if selected_client else []
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
online = _node_online(node)
live_result = None
analysis_history = []
command_error = error or ""
should_query_agent = bool(refresh or selected_client)
if refresh:
try:
require_permission(db, user, "accounting.tally.connect")
except Exception:
return _denied()
if should_query_agent and node and online:
payload = {}
if selected_client:
payload = _accounting_storage_payload(selected_client)
try:
response_data = request_agent_command(
node.node_code,
"phase4_status",
payload,
timeout_seconds=20,
)
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:
command_error = str(exc)
today = date.today()
fy_start_year = today.year if today.month >= 4 else today.year - 1
transaction_date_from = date(fy_start_year, 4, 1).isoformat()
transaction_date_to = today.isoformat()
return _render(
request,
db,
user,
title="Tally Accounting",
clients=clients,
selected_client=selected_client,
registrations=registrations,
storage_node=node,
agent_online=online,
live_result=live_result,
initialized=bool(initialized),
mapped=bool(mapped),
unmapped=bool(unmapped),
synced=bool(synced),
transactions_synced=bool(transactions_synced),
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()
@router.post("/initialize")
def initialize_accounting_storage(
request: Request,
client_id: int = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.act.initialize")
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,
)
try:
result = request_agent_command(
node.node_code,
"accounting_initialize",
{
**_accounting_storage_payload(client),
"tenant_id": int(scope.tenant_id),
"requested_by_user_id": int(user.id),
},
timeout_seconds=20,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Accounting storage initialization failed."))
except Exception as exc:
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}",
status_code=303,
)
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&refresh=1&initialized=1",
status_code=303,
)
finally:
db.close()
@router.post("/map-company")
def map_tally_company(
request: Request,
client_id: int = Form(...),
tally_guid: str = Form(...),
registration_id: str = Form(""),
allow_gstin_mismatch: bool = Form(False),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.map_company")
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,
)
registration_payload = None
if str(registration_id or "").strip():
try:
rid = int(registration_id)
except Exception:
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&error={quote('Invalid registration selection.')}",
status_code=303,
)
row = db.execute(
select(ClientRegistration, RegistrationType)
.join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id)
.where(
ClientRegistration.id == rid,
ClientRegistration.client_id == client.id,
ClientRegistration.tenant_id == scope.tenant_id,
)
).first()
if not row:
return _denied()
registration_payload = _registration_payload(row[0], row[1])
try:
result = request_agent_command(
node.node_code,
"accounting_map_company",
{
**_accounting_storage_payload(client),
"tenant_id": int(scope.tenant_id),
"registration": registration_payload,
"tally_guid": str(tally_guid or "").strip(),
"allow_gstin_mismatch": bool(allow_gstin_mismatch),
"mapped_by_user_id": int(user.id),
},
timeout_seconds=20,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Tally company mapping failed."))
except Exception as exc:
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}",
status_code=303,
)
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&refresh=1&mapped=1",
status_code=303,
)
finally:
db.close()
@router.post("/unmap-company")
def unmap_tally_company(
request: Request,
client_id: int = Form(...),
mapping_id: int = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.map_company")
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,
)
try:
result = request_agent_command(
node.node_code,
"accounting_unmap_company",
{
**_accounting_storage_payload(client),
"mapping_id": int(mapping_id),
"unmapped_by_user_id": int(user.id),
},
timeout_seconds=20,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Tally company unmapping failed."))
except Exception as exc:
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}",
status_code=303,
)
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&refresh=1&unmapped=1",
status_code=303,
)
finally:
db.close()
@router.post("/sync-masters")
def sync_tally_masters(
request: Request,
client_id: int = Form(...),
tally_guid: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.sync_masters")
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,
)
try:
result = request_agent_command(
node.node_code,
"accounting_sync_masters",
{
**_accounting_storage_payload(client),
"tally_guid": str(tally_guid or "").strip(),
"requested_by_user_id": int(user.id),
},
timeout_seconds=120,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Tally master synchronization failed."))
except Exception as exc:
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}",
status_code=303,
)
return RedirectResponse(
url=f"/tools/tally?client_id={client.id}&refresh=1&synced=1",
status_code=303,
)
finally:
db.close()
@router.post("/sync-transactions")
def sync_tally_transactions(
request: Request,
client_id: int = Form(...),
tally_guid: str = Form(...),
date_from: str = Form(...),
date_to: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.sync_transactions")
if response:
return response
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _denied()
try:
start = date.fromisoformat(str(date_from or ""))
end = date.fromisoformat(str(date_to or ""))
if start > end:
raise ValueError("From Date cannot be after To Date.")
if (end - start).days > 370:
raise ValueError("Transaction sync is limited to 371 days per run. Sync additional periods separately.")
except Exception as exc:
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}", status_code=303)
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)
try:
result = request_agent_command(
node.node_code,
"accounting_sync_transactions",
{
**_accounting_storage_payload(client, _financial_year_for_date(start)),
"tally_guid": str(tally_guid or "").strip(),
"date_from": start.isoformat(),
"date_to": end.isoformat(),
"requested_by_user_id": int(user.id),
},
timeout_seconds=300,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Tally transaction synchronization failed."))
except Exception as exc:
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}", status_code=303)
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&refresh=1&transactions_synced=1", status_code=303)
finally:
db.close()
@router.post("/full-export")
def start_full_accounting_export(
request: Request,
client_id: int = Form(...),
tally_guid: str = Form(...),
date_from: str = Form(...),
date_to: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.sync_transactions")
if response:
return response
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _denied()
try:
start = date.fromisoformat(str(date_from or ""))
end = date.fromisoformat(str(date_to or ""))
if start > end:
raise ValueError("From Date cannot be after To Date.")
except Exception as exc:
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&error={quote(str(exc))}", status_code=303)
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_full_export_start",
{
**_accounting_storage_payload(client, _financial_year_for_date(start)),
"tally_guid": str(tally_guid or "").strip(),
"date_from": start.isoformat(), "date_to": end.isoformat(),
"requested_by_user_id": int(user.id), "timeout_seconds": 1800,
}, timeout_seconds=20,
)
if not result.get("ok"):
raise RuntimeError(str(result.get("error") or "Full Accounting Export could not be started."))
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&refresh=1&export_started=1", status_code=303)
except Exception as exc:
return RedirectResponse(url=f"/tools/tally?client_id={client_id}&error={quote(str(exc))}", status_code=303)
finally:
db.close()
@router.get("/full-export/status")
def full_accounting_export_status(request: Request, client_id: int):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.view")
if response:
return JSONResponse({"ok": False, "error": "Access denied."}, status_code=403)
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return JSONResponse({"ok": False, "error": "Client is not available."}, 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=503)
result = request_agent_command(
node.node_code, "accounting_full_export_status", _accounting_storage_payload(client), timeout_seconds=10
)
if not result.get("ok"):
return JSONResponse({"ok": False, "error": str(result.get("error") or "Progress lookup failed.")}, status_code=500)
return JSONResponse({"ok": True, **(result.get("result") or {})})
finally:
db.close()
@router.get("/depreciation")
def depreciation_it_tool(
request: Request,
client_id: int | None = None,
fy_start: str = "",
fy_end: str = "",
calculated: int = 0,
approved: int = 0,
posted: int = 0,
run_id: int | None = None,
error: str = "",
):
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.depreciation_it")
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()
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, "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_for_financial_year"):
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",
{**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")
async def calculate_it_depreciation(request: Request):
form = await request.form()
csrf_token = str(form.get("csrf_token") or ""); validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.depreciation_it")
if response: return response
try: client_id=int(form.get("client_id")); start=date.fromisoformat(str(form.get("fy_start") or "")); end=date.fromisoformat(str(form.get("fy_end") or ""))
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()
assignments=[]
for key,value in form.multi_items():
if not str(key).startswith("rate__"): continue
ledger_key=str(key)[6:]
try: rate=float(str(value or "0").strip() or 0)
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}&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)),"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}&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()
@router.post("/depreciation/approve")
async def approve_it_depreciation(request: Request):
form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or ""))
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.writeback")
if response: return response
try:
client_id = int(form.get("client_id")); run_id = int(form.get("run_id"))
approval_fy_start = date.fromisoformat(str(form.get("fy_start") or ""))
except Exception:
return RedirectResponse(url="/tools/tally/depreciation?error=" + quote("Invalid depreciation draft or financial year."), status_code=303)
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client: return _denied()
if str(form.get("confirm_approval") or "") != "yes":
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('Confirm that you reviewed the depreciation draft before approval.')}", status_code=303)
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}&run_id={run_id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303)
try:
result = request_agent_command(
node.node_code, "accounting_approve_it_depreciation",
{**_accounting_storage_payload(client, _financial_year_for_date(approval_fy_start)), "run_id": run_id, "approved_by_user_id": int(user.id), "approval_note": str(form.get("approval_note") or "")},
timeout_seconds=30,
)
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}&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:
db.close()
@router.post("/depreciation/post-to-tally")
async def post_it_depreciation_to_tally(request: Request):
form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or ""))
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.writeback")
if response: return response
try:
client_id = int(form.get("client_id")); run_id = int(form.get("run_id"))
posting_fy_start = date.fromisoformat(str(form.get("fy_start") or ""))
except Exception:
return RedirectResponse(url="/tools/tally/depreciation?error=" + quote("Invalid depreciation draft or financial year."), status_code=303)
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client: return _denied()
if str(form.get("post_confirmation") or "").strip().upper() != "POST":
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('Type POST exactly to confirm Tally write-back.')}", status_code=303)
if str(form.get("confirm_posting") or "") != "yes":
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('Confirm that you want to create the Journal voucher in TallyPrime.')}", status_code=303)
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}&run_id={run_id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303)
try:
result = request_agent_command(
node.node_code, "accounting_post_it_depreciation",
{**_accounting_storage_payload(client, _financial_year_for_date(posting_fy_start)), "run_id": run_id, "posted_by_user_id": int(user.id)},
timeout_seconds=120,
)
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}&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:
db.close()