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