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.accounting.accounting_mirror_service import ( get_registered_mirror, get_current_accounting_mirror, registered_mirror_payload, list_registered_mirrors, list_accounting_mirror_versions, next_accounting_mirror_version, register_accounting_mirror_version, sync_discovered_mirrors, upsert_registered_mirror, ) 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 _active_financial_year(request: Request) -> str: """Return the FY selected in the ERP workspace header.""" value = ( request.session.get("active_financial_year") or getattr(request.state, "year_code", None) or "" ) value = str(value or "").strip() if re.fullmatch(r"\d{4}-\d{2}", value): return value return _financial_year_for_date() def _financial_year_bounds(financial_year: str) -> tuple[date, date]: match = re.fullmatch(r"(\d{4})-(\d{2})", str(financial_year or "").strip()) if not match: raise ValueError("Invalid financial year.") start_year = int(match.group(1)) return date(start_year, 4, 1), date(start_year + 1, 3, 31) def _financial_year_options(reference_fy: str | None = None, years_back: int = 7) -> list[str]: reference = str(reference_fy or _financial_year_for_date()).strip() start, _ = _financial_year_bounds(reference) return [ f"{year}-{str(year + 1)[-2:]}" for year in range(start.year, start.year - max(1, int(years_back)), -1) ] def _accounting_storage_payload(client, financial_year: str | None = None, *, db=None, tenant_id: int | None = None) -> dict: """Resolve the Accounting Mirror path for one Client/FY. The deterministic FY/client path remains the backward-compatible fallback. If a current mirror metadata row exists, its registered path/version becomes the authoritative payload for every Accounting tool importing this helper. This keeps old tools working while removing repeated mirror/path selection. """ 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" fallback = { "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(), "mirror_registered": False, } own_db = None try: session = db if session is None: own_db = CommonSessionLocal() session = own_db resolved_tenant_id = int(tenant_id or getattr(client, "tenant_id", 0) or 0) if not resolved_tenant_id: return fallback resolved = registered_mirror_payload( session, tenant_id=resolved_tenant_id, client_id=int(client.id), financial_year=fy_value, fallback=fallback, ) if resolved.get("mirror_registered") and str(resolved.get("mirror_local_path") or "").strip(): return resolved # Backfill mirrors created before the registry existed, and enrich older # registry rows that pre-date mirror_local_path metadata. This is deliberately # read-only on the workstation: the agent only lists canonical .act files; # sync_discovered_mirrors creates missing metadata rows in PostgreSQL. try: branch_id = getattr(client, "branch_id", None) node = get_active_storage_node_for_branch(session, resolved_tenant_id, branch_id) if node and _node_online(node): response = request_agent_command( node.node_code, "accounting_mirror_list", {"client_id": int(client.id)}, timeout_seconds=8, ) if response.get("ok"): sync_discovered_mirrors( session, tenant_id=resolved_tenant_id, client_id=int(client.id), storage_node_id=int(node.id), discovered=(response.get("result") or {}).get("mirrors") or [], requested_by_user_id=None, ) resolved = registered_mirror_payload( session, tenant_id=resolved_tenant_id, client_id=int(client.id), financial_year=fy_value, fallback=fallback, ) except Exception: pass return resolved except Exception: # Registry lookup must never break an existing Accounting tool; legacy # deterministic path resolution remains available until backfill occurs. return fallback finally: if own_db is not None: own_db.close() def _analysis_accounting_storage_payload( client, financial_year: str | None = None, *, db=None, tenant_id: int | None = None, ) -> dict: """Return the authoritative registered Client/FY mirror payload for analysis jobs. Analytical procedures must never silently fall back to a deterministic folder. The fallback remains available to bootstrap/sync/write workflows through ``_accounting_storage_payload``. Requiring a registry row here guarantees that every analysis is tied to the same Client/FY/version selected by ``AccountingMirrorRegistry``. """ payload = _accounting_storage_payload( client, financial_year, db=db, tenant_id=tenant_id, ) if not bool(payload.get("mirror_registered")): fy_value = str(payload.get("financial_year") or financial_year or "").strip() raise ValueError( f"No registered Accounting Mirror is available for FY {fy_value}. " "Create or register the Client/FY mirror from Tally Accounting first." ) if not str(payload.get("accounting_relative_dir") or "").strip(): raise ValueError("The registered Accounting Mirror has no storage path.") payload["analysis_source"] = "accounting_mirror_registry" return payload 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, financial_year: str = "", 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) # Synchronise the cloud-side Client + FY registry from mirrors already # present on the branch Storage Node. This is read-only and also backfills # mirrors created before the registry feature was introduced. if selected_client and node and online: try: discovered_response = request_agent_command( node.node_code, "accounting_mirror_list", {"client_id": int(selected_client.id)}, timeout_seconds=20, ) if discovered_response.get("ok"): sync_discovered_mirrors( db, tenant_id=scope.tenant_id, client_id=int(selected_client.id), storage_node_id=int(node.id), discovered=(discovered_response.get("result") or {}).get("mirrors") or [], requested_by_user_id=int(user.id), ) except Exception: # Mirror discovery must never block the existing Tally page. pass live_result = None analysis_history = [] command_error = error or "" workspace_financial_year = _active_financial_year(request) selected_financial_year = str(financial_year or workspace_financial_year).strip() try: selected_fy_start, selected_fy_end = _financial_year_bounds(selected_financial_year) except Exception: selected_financial_year = workspace_financial_year selected_fy_start, selected_fy_end = _financial_year_bounds(selected_financial_year) mirror_fy_options = _financial_year_options(workspace_financial_year, 7) 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, selected_financial_year) 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() transaction_date_from = selected_fy_start.isoformat() transaction_date_to = ( min(today, selected_fy_end).isoformat() if selected_financial_year == _financial_year_for_date(today) else selected_fy_end.isoformat() ) registered_mirrors = ( list_registered_mirrors(db, scope.tenant_id, int(selected_client.id)) if selected_client else [] ) selected_mirror_registration = ( get_registered_mirror(db, scope.tenant_id, int(selected_client.id), selected_financial_year) if selected_client else None ) selected_mirror_versions = ( list_accounting_mirror_versions(db, scope.tenant_id, int(selected_client.id), selected_financial_year) if selected_client else [] ) 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, selected_financial_year=selected_financial_year, workspace_financial_year=workspace_financial_year, mirror_fy_options=mirror_fy_options, registered_mirrors=registered_mirrors, selected_mirror_registration=selected_mirror_registration, selected_mirror_versions=selected_mirror_versions, 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", { **_analysis_accounting_storage_payload(client, financial_year, db=db, tenant_id=scope.tenant_id), "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.get("/mirror-registration/status") def mirror_registration_status(request: Request, client_id: int, financial_year: str): 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) try: _financial_year_bounds(financial_year) except Exception: return JSONResponse({"ok": False, "error": "Invalid financial year."}, status_code=400) row = get_registered_mirror(db, scope.tenant_id, int(client.id), financial_year) node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) file_exists = False mirror = {} if node and _node_online(node): try: storage_payload = _accounting_storage_payload(client, financial_year) status = request_agent_command(node.node_code, "accounting_mirror_status", storage_payload, timeout_seconds=20) if status.get("ok"): mirror = (status.get("result") or {}).get("mirror") or {} file_exists = bool(mirror.get("ready")) if file_exists: row = upsert_registered_mirror( db, tenant_id=scope.tenant_id, client_id=int(client.id), financial_year=financial_year, accounting_relative_dir=storage_payload["accounting_relative_dir"], storage_node_id=int(node.id), mirror=mirror, requested_by_user_id=int(user.id), replacement=False, ) db.commit() except Exception: pass exists = bool(row or file_exists) return JSONResponse({ "ok": True, "exists": exists, "financial_year": financial_year, "client_name": client.client_name, "company_name": (row.company_name if row else str((mirror.get("company") or {}).get("company_name") or "")), "last_synced_at": (row.last_synced_at_utc.isoformat() if row and row.last_synced_at_utc else ""), "version_no": (int(row.version_no) if row else 0), "version_count": (len(list_accounting_mirror_versions(db, scope.tenant_id, int(client.id), financial_year)) if row else 0), }) 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(...), financial_year: str = Form(""), replace_existing: 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.") target_fy = str(financial_year or _financial_year_for_date(start)).strip() fy_start, fy_end = _financial_year_bounds(target_fy) if start < fy_start or end > fy_end: raise ValueError( f"Selected dates must fall within FY {target_fy} " f"({fy_start.isoformat()} to {fy_end.isoformat()})." ) 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) storage_payload = _accounting_storage_payload(client, target_fy) existing_registration = get_registered_mirror(db, scope.tenant_id, int(client.id), target_fy) existing_file = False try: current_status = request_agent_command( node.node_code, "accounting_mirror_status", storage_payload, timeout_seconds=20 ) current_mirror = ((current_status.get("result") or {}).get("mirror") or {}) if current_status.get("ok") else {} existing_file = bool(current_mirror.get("ready")) if existing_file: upsert_registered_mirror( db, tenant_id=scope.tenant_id, client_id=int(client.id), financial_year=target_fy, accounting_relative_dir=storage_payload["accounting_relative_dir"], storage_node_id=int(node.id), mirror=current_mirror, requested_by_user_id=int(user.id), replacement=False, ) db.commit() except Exception: existing_file = bool(existing_registration) replace_confirmed = str(replace_existing or "").strip().lower() in {"1", "true", "yes", "on"} if (existing_registration or existing_file) and not replace_confirmed: current_version = int(existing_registration.version_no) if existing_registration else 1 message = ( f"An Accounting Mirror already exists for {client.client_name} - FY {target_fy} as version {current_version}. " "Use Create New Version to refresh it; the existing version remains available if the new export fails." ) return RedirectResponse( url=f"/tools/tally?client_id={client.id}&financial_year={quote(target_fy)}&error={quote(message)}", status_code=303, ) current_version_no = int(existing_registration.version_no) if existing_registration else (1 if existing_file else 0) mirror_version_no = (current_version_no + 1) if (existing_registration or existing_file) else 1 result = request_agent_command( node.node_code, "accounting_full_export_start", { **storage_payload, "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, "replacement_requested": bool(existing_registration or existing_file), "previous_version_no": current_version_no, "mirror_version_no": mirror_version_no, }, 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}&financial_year={quote(target_fy)}&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, financial_year: str = ""): 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", {"client_id": int(client.id)}, timeout_seconds=10 ) if not result.get("ok"): return JSONResponse({"ok": False, "error": str(result.get("error") or "Progress lookup failed.")}, status_code=500) result_payload = result.get("result") or {} job = result_payload.get("job") or {} if str(job.get("status") or "").lower() == "completed": fy = str(job.get("financial_year") or financial_year or "").strip() if re.fullmatch(r"\d{4}-\d{2}", fy): storage_payload = _accounting_storage_payload(client, fy) try: if bool(job.get("replacement_requested")): register_accounting_mirror_version( db, tenant_id=scope.tenant_id, client_id=int(client.id), financial_year=fy, accounting_relative_dir=storage_payload["accounting_relative_dir"], storage_node_id=int(node.id), mirror=job.get("mirror") or {}, job=job, requested_by_user_id=int(user.id), version_no=int(job.get("mirror_version_no") or 0) or None, archived_previous_file_name=str(job.get("archived_previous_file_name") or ""), ) else: upsert_registered_mirror( db, tenant_id=scope.tenant_id, client_id=int(client.id), financial_year=fy, accounting_relative_dir=storage_payload["accounting_relative_dir"], storage_node_id=int(node.id), mirror=job.get("mirror") or {}, job=job, requested_by_user_id=int(user.id), replacement=False, ) db.commit() except Exception: db.rollback() return JSONResponse({"ok": True, **result_payload}) 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() active_start_year = today.year if today.month >= 4 else today.year - 1 explicit_period = bool(str(fy_start or "").strip() and str(fy_end or "").strip()) start_text = fy_start or date(active_start_year, 4, 1).isoformat() end_text = fy_end or date(active_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: selected_fy=_financial_year_for_date(date.fromisoformat(start_text)) storage_payload=_analysis_accounting_storage_payload(selected_client,selected_fy,db=db,tenant_id=scope.tenant_id) 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 not explicit_period and not ((mirror_status or {}).get("mirror") or {}).get("ready_for_financial_year"): prior_start_year=active_start_year-1 prior_start=date(prior_start_year,4,1) prior_end=date(prior_start_year+1,3,31) prior_fy=_financial_year_for_date(prior_start) prior_payload=_analysis_accounting_storage_payload(selected_client,prior_fy,db=db,tenant_id=scope.tenant_id) prior_response=request_agent_command( node.node_code,"accounting_mirror_status",prior_payload,timeout_seconds=20 ) prior_status=(prior_response.get("result") or {}) if prior_response.get("ok") else {} if ((prior_status.get("mirror") or {}).get("ready_for_financial_year")): start_text=prior_start.isoformat(); end_text=prior_end.isoformat() storage_payload=prior_payload; mirror_status=prior_status 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",{**_analysis_accounting_storage_payload(client, _financial_year_for_date(start), db=db, tenant_id=scope.tenant_id),"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", {**_analysis_accounting_storage_payload(client, _financial_year_for_date(approval_fy_start), db=db, tenant_id=scope.tenant_id), "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", {**_analysis_accounting_storage_payload(client, _financial_year_for_date(posting_fy_start), db=db, tenant_id=scope.tenant_id), "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()