from __future__ import annotations from urllib.parse import urlencode 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.templating import templates from app.modules.accounting.agent_bridge import request_agent_command from app.modules.accounting.chart_models import AccountingChartSyncRun from app.modules.accounting.chart_service import ( LEDGER_ROLES, ROLE_LABELS, chart_summary, effective_role, list_groups, list_ledgers, persist_chart_snapshot, root_group_options, set_manual_ledger_role, ) from app.modules.accounting.ui import ( _accounting_storage_payload, _find_visible_client, _node_online, _require_partner, _visible_clients, ) 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 router = APIRouter( prefix="/tools/accounting/chart-of-accounts", tags=["accounting-chart-of-accounts-ui"], ) def _go(client_id=0, message="", error="", **filters): q = {} if client_id: q["client_id"] = client_id if message: q["message"] = message[:350] if error: q["error"] = error[:350] for key, value in filters.items(): if value not in (None, "", "all"): q[key] = value return RedirectResponse( "/tools/accounting/chart-of-accounts" + ("?" + urlencode(q) if q else ""), status_code=303, ) @router.get("") def page( request: Request, client_id: int | None = None, tally_guid: str = "", view: str = "ledgers", q: str = "", role: str = "", root_group: str = "", page: int = 1, per_page: int = 50, message: str = "", error: str = "", ): db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.tally.view") if denied: return denied clients, scope = _visible_clients(db, request, user) selected = next( (client for client in clients if client_id and int(client.id) == int(client_id)), None, ) node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) online = bool(node and _node_online(node)) tally = None accounting = None command_error = error or "" if selected and node and online: try: response = request_agent_command( node.node_code, "phase6_status", _accounting_storage_payload(selected), timeout_seconds=20, ) if response.get("ok"): result = response.get("result") or {} tally = result.get("tally") accounting = result.get("accounting") else: command_error = str(response.get("error") or "Local Agent status failed.") except Exception as exc: command_error = str(exc) mapped_guids = set() mapping_names = {} if accounting: for mapping in accounting.get("mappings") or []: guid = str(mapping.get("tally_guid") or "").strip() if guid: mapped_guids.add(guid) mapping_names[guid] = str(mapping.get("company_name") or "").strip() if tally_guid and tally_guid not in mapped_guids: tally_guid = "" available_mappings = [ { "guid": guid, "name": mapping_names.get(guid, guid), "currently_loaded": bool( any( str(c.get("guid") or "").strip() == guid for c in ((tally or {}).get("companies") or []) ) ), } for guid in sorted(mapped_guids, key=lambda g: mapping_names.get(g, g).casefold()) ] if not tally_guid and len(available_mappings) == 1: tally_guid = available_mappings[0]["guid"] summary = {"groups": 0, "ledgers": 0, "manual": 0, "review": 0, "classified": 0} ledgers = [] groups = [] total = 0 pages = 1 roots = [] last_sync = None if selected: summary = chart_summary( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, ) roots = root_group_options( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, ) if view == "groups": groups = list_groups( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, ) else: ledgers, total, page, pages = list_ledgers( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, q=q, role=role, root_group=root_group, page=page, per_page=per_page, ) sync_stmt = select(AccountingChartSyncRun).where( AccountingChartSyncRun.tenant_id == scope.tenant_id, AccountingChartSyncRun.client_id == selected.id, ) if tally_guid: sync_stmt = sync_stmt.where(AccountingChartSyncRun.tally_guid == tally_guid) last_sync = db.execute( sync_stmt.order_by(AccountingChartSyncRun.id.desc()).limit(1) ).scalar_one_or_none() return templates.TemplateResponse( "modules/accounting/templates/accounting/chart_of_accounts.html", { "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), "title": "Chart of Accounts", "clients": clients, "selected_client": selected, "node_online": online, "tally": tally, "accounting": accounting, "available_mappings": available_mappings, "selected_tally_guid": tally_guid, "summary": summary, "ledgers": ledgers, "groups": groups, "root_groups": roots, "role_options": LEDGER_ROLES, "role_labels": ROLE_LABELS, "effective_role": effective_role, "view": view if view in {"ledgers", "groups"} else "ledgers", "q": q, "role_filter": role, "root_group_filter": root_group, "page": page, "pages": pages, "per_page": per_page, "total": total, "last_sync": last_sync, "message": message, "error": command_error, }, ) finally: db.close() @router.post("/sync") def sync_chart( request: Request, client_id: int = Form(...), tally_guid: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.tally.sync_masters") if denied: return denied client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) if not node or not _node_online(node): return _go(client.id, error="ERP Local Agent is offline for the active branch.") response = request_agent_command( node.node_code, "accounting_chart_of_accounts", { **_accounting_storage_payload(client), "tally_guid": str(tally_guid or "").strip(), "requested_by_user_id": int(user.id), }, timeout_seconds=180, ) if not response.get("ok"): raise RuntimeError(str(response.get("error") or "Chart of Accounts synchronization failed.")) result = response.get("result") or {} chart = result.get("chart_of_accounts") or {} groups = chart.get("groups") or [] ledgers = chart.get("ledgers") or [] company_name = str( chart.get("company_name") or (result.get("sync") or {}).get("company_name") or "" ).strip() run = persist_chart_snapshot( db, tenant_id=scope.tenant_id, client_id=client.id, tally_guid=str(tally_guid or "").strip(), company_name=company_name, groups=groups, ledgers=ledgers, user_id=user.id, ) return _go( client.id, tally_guid=tally_guid, message=( f"Chart of Accounts refreshed: {run.group_count} group(s), " f"{run.ledger_count} ledger(s)." ), ) except Exception as exc: db.rollback() return _go(client_id, tally_guid=tally_guid, error=str(exc)) finally: db.close() @router.post("/ledger/{ledger_id}/role") def update_ledger_role( request: Request, ledger_id: int, client_id: int = Form(...), tally_guid: str = Form(""), role_code: str = Form(""), note: str = Form(""), q: str = Form(""), role_filter: str = Form(""), root_group: str = Form(""), page: int = Form(1), per_page: int = Form(50), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.learning.manage") if denied: return denied client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") set_manual_ledger_role( db, tenant_id=scope.tenant_id, client_id=client.id, ledger_id=ledger_id, role_code=role_code, note=note, user_id=user.id, ) return _go( client.id, tally_guid=tally_guid, q=q, role=role_filter, root_group=root_group, page=page, per_page=per_page, message="Ledger role updated.", ) except Exception as exc: db.rollback() return _go(client_id, tally_guid=tally_guid, error=str(exc)) finally: db.close()