from __future__ import annotations from datetime import datetime, timezone from urllib.parse import quote from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse from sqlalchemy import select 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 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 _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 _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, 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 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 = { "client_id": int(selected_client.id), "client_name": selected_client.client_name, } try: response_data = request_agent_command( node.node_code, "phase3_status", payload, timeout_seconds=20, ) if response_data.get("ok"): live_result = response_data.get("result") or {} else: command_error = str(response_data.get("error") or "Local agent command failed.") except Exception as exc: command_error = str(exc) return _render( request, db, user, title="Tally Accounting Masters", 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), command_error=command_error, ) 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", { "client_id": int(client.id), "client_name": client.client_name, "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", { "client_id": int(client.id), "client_name": client.client_name, "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", { "client_id": int(client.id), "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", { "client_id": int(client.id), "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()