Files
arrr-erp/app/modules/accounting/ui.py
T
2026-09-20 16:36:14 +05:30

1340 lines
60 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.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()