Files
arrr-erp/app/modules/accounting/financial_statements_ui.py
2026-09-21 16:07:05 +05:30

373 lines
15 KiB
Python

from __future__ import annotations
from urllib.parse import urlencode
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse, StreamingResponse
from sqlalchemy import delete, select
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.templating import templates
from app.modules.accounting.accounting_mirror_service import get_registered_mirror
from app.modules.accounting.financial_statements_models import (
AccountingFinancialLedgerMapping,
AccountingPartnershipProfitShare,
)
from app.modules.accounting.financial_statements_service import (
MAPPING_CHOICES,
SCHEDULES,
build_excel_workbook,
build_financial_statements,
load_mirror_trial_balance,
previous_financial_year,
)
from app.modules.accounting.ui import (
_active_financial_year,
_analysis_accounting_storage_payload,
_financial_year_options,
_find_visible_client,
_node_online,
_require_partner,
_visible_clients,
)
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.documents.services import get_active_storage_node_for_branch
from app.modules.registrations.models import ClientRelatedPerson
router = APIRouter(prefix="/tools/accounting/financial-statements", tags=["accounting-financial-statements-ui"])
def _go(client_id: int = 0, financial_year: str = "", message: str = "", error: str = ""):
q = {}
if client_id:
q["client_id"] = client_id
if financial_year:
q["financial_year"] = financial_year
if message:
q["message"] = message[:450]
if error:
q["error"] = error[:450]
return RedirectResponse("/tools/accounting/financial-statements" + ("?" + urlencode(q) if q else ""), status_code=303)
def _is_partnership(client) -> bool:
text = str(getattr(client, "client_type", "") or "").strip().lower()
return any(token in text for token in ("partnership", "firm", "llp"))
def _partner_shares(db, *, tenant_id: int, client_id: int, financial_year: str):
return db.execute(
select(AccountingPartnershipProfitShare)
.where(
AccountingPartnershipProfitShare.tenant_id == int(tenant_id),
AccountingPartnershipProfitShare.client_id == int(client_id),
AccountingPartnershipProfitShare.financial_year == financial_year,
)
.order_by(AccountingPartnershipProfitShare.partner_name.asc())
).scalars().all()
def _share_payload(rows):
return [
{
"partner_name": row.partner_name,
"profit_share_percent": float(row.profit_share_percent or 0),
"target_ledger_name": row.target_ledger_name or "",
}
for row in rows
]
def _default_partners(db, *, tenant_id: int, client_id: int):
rows = db.execute(
select(ClientRelatedPerson).where(
ClientRelatedPerson.tenant_id == int(tenant_id),
ClientRelatedPerson.client_id == int(client_id),
ClientRelatedPerson.is_active.is_(True),
).order_by(ClientRelatedPerson.full_name.asc())
).scalars().all()
return [row for row in rows if "partner" in str(row.person_type or "").lower()]
def _load_report(db, request: Request, user, client, scope, financial_year: str):
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
raise ValueError("ERP Local Agent for the active branch is offline.")
current_payload = _analysis_accounting_storage_payload(
client, financial_year, db=db, tenant_id=scope.tenant_id
)
current = load_mirror_trial_balance(
node_code=node.node_code,
accounting_payload=current_payload,
db=db,
tenant_id=scope.tenant_id,
client_id=client.id,
)
previous_fy = previous_financial_year(financial_year)
previous = {"lines": [], "count": 0, "group_count": 0, "hierarchy_warning": ""}
previous_available = False
try:
previous_payload = _analysis_accounting_storage_payload(
client, previous_fy, db=db, tenant_id=scope.tenant_id
)
previous = load_mirror_trial_balance(
node_code=node.node_code,
accounting_payload=previous_payload,
db=db,
tenant_id=scope.tenant_id,
client_id=client.id,
)
previous_available = True
except Exception:
pass
shares = _partner_shares(
db, tenant_id=scope.tenant_id, client_id=client.id, financial_year=financial_year
) if _is_partnership(client) else []
share_payload = _share_payload(shares)
statements = build_financial_statements(
current_lines=current["lines"],
previous_lines=previous["lines"],
partnership_shares=share_payload,
)
return {
"node": node,
"current_payload": current_payload,
"current": current,
"previous_fy": previous_fy,
"previous": previous,
"previous_available": previous_available,
"shares": shares,
"share_payload": share_payload,
"statements": statements,
}
@router.get("")
def page(
request: Request,
client_id: int | None = None,
financial_year: str = "",
message: str = "",
error: str = "",
):
db = CommonSessionLocal()
try:
user, denied = _require_partner(request, db, "accounting.tally.view")
if denied:
return denied
clients, scope = _visible_clients(db, request, user)
selected = next((c for c in clients if client_id and int(c.id) == int(client_id)), None)
fy = str(financial_year or _active_financial_year(request)).strip()
if fy not in _financial_year_options(_active_financial_year(request), years_back=10):
fy = _active_financial_year(request)
report = None
command_error = error or ""
if selected:
try:
report = _load_report(db, request, user, selected, scope, fy)
except Exception as exc:
command_error = str(exc)
default_partners = _default_partners(db, tenant_id=scope.tenant_id, client_id=selected.id) if selected and _is_partnership(selected) else []
existing_names = {str(x.partner_name or "").strip().casefold() for x in (report["shares"] if report else [])}
partner_rows = list(report["shares"] if report else [])
for person in default_partners:
if str(person.full_name or "").strip().casefold() not in existing_names:
partner_rows.append(type("PartnerDraft", (), {
"partner_name": person.full_name,
"profit_share_percent": 0,
"target_ledger_name": "",
})())
return templates.TemplateResponse(
"modules/accounting/templates/accounting/financial_statements.html",
{
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
"clients": clients,
"selected_client": selected,
"financial_year": fy,
"financial_year_options": _financial_year_options(_active_financial_year(request), years_back=10),
"report": report,
"statements": report["statements"] if report else None,
"current_lines": report["current"]["lines"] if report else [],
"previous_fy": report["previous_fy"] if report else previous_financial_year(fy),
"previous_available": bool(report and report["previous_available"]),
"partner_rows": partner_rows,
"is_partnership": bool(selected and _is_partnership(selected)),
"mapping_choices": MAPPING_CHOICES,
"schedules": SCHEDULES,
"message": message,
"command_error": command_error,
},
)
finally:
db.close()
@router.post("/mapping")
def save_mapping(
request: Request,
client_id: int = Form(...),
financial_year: str = Form(...),
ledger_name: str = Form(...),
schedule_code: str = Form(...),
csrf_token: str = Form(...),
):
db = CommonSessionLocal()
try:
user, denied = _require_partner(request, db, "accounting.tally.view")
if denied:
return denied
validate_csrf(request, csrf_token)
client, _, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _go(error="Client is not available in the active workspace.")
code = str(schedule_code or "").strip().upper()
if code not in SCHEDULES or code == "UNMAPPED":
return _go(client_id, financial_year, error="Select a valid financial statement schedule.")
name = str(ledger_name or "").strip()
if not name:
return _go(client_id, financial_year, error="Ledger name is required.")
row = db.execute(select(AccountingFinancialLedgerMapping).where(
AccountingFinancialLedgerMapping.tenant_id == scope.tenant_id,
AccountingFinancialLedgerMapping.client_id == client.id,
AccountingFinancialLedgerMapping.ledger_name == name,
)).scalar_one_or_none()
meta = SCHEDULES[code]
if row is None:
row = AccountingFinancialLedgerMapping(
tenant_id=scope.tenant_id, client_id=client.id, ledger_name=name,
statement_code=meta["statement"], schedule_code=code,
display_group=meta["label"], updated_by_user_id=user.id,
)
db.add(row)
else:
row.statement_code=meta["statement"]
row.schedule_code=code
row.display_group=meta["label"]
row.updated_by_user_id=user.id
db.commit()
return _go(client.id, financial_year, message=f"Reporting mapping saved for {name}.")
except Exception as exc:
db.rollback()
return _go(client_id, financial_year, error=str(exc))
finally:
db.close()
@router.post("/mapping/reset")
def reset_mapping(
request: Request,
client_id: int = Form(...),
financial_year: str = Form(...),
ledger_name: str = Form(...),
csrf_token: str = Form(...),
):
db=CommonSessionLocal()
try:
user, denied=_require_partner(request,db,"accounting.tally.view")
if denied: return denied
validate_csrf(request,csrf_token)
client,_,scope=_find_visible_client(db,request,user,client_id)
if not client: return _go(error="Client is not available in the active workspace.")
db.execute(delete(AccountingFinancialLedgerMapping).where(
AccountingFinancialLedgerMapping.tenant_id==scope.tenant_id,
AccountingFinancialLedgerMapping.client_id==client.id,
AccountingFinancialLedgerMapping.ledger_name==str(ledger_name or "").strip(),
))
db.commit()
return _go(client.id,financial_year,message="Manual mapping removed; Tally group mapping is active again.")
except Exception as exc:
db.rollback(); return _go(client_id,financial_year,error=str(exc))
finally: db.close()
@router.post("/partnership")
def save_partnership(
request: Request,
client_id: int = Form(...),
financial_year: str = Form(...),
partner_name: list[str] = Form(default=[]),
profit_share_percent: list[str] = Form(default=[]),
target_ledger_name: list[str] = Form(default=[]),
csrf_token: str = Form(...),
):
db=CommonSessionLocal()
try:
user, denied=_require_partner(request,db,"accounting.tally.view")
if denied: return denied
validate_csrf(request,csrf_token)
client,_,scope=_find_visible_client(db,request,user,client_id)
if not client: return _go(error="Client is not available in the active workspace.")
if not _is_partnership(client): return _go(client.id,financial_year,error="Partnership allocation is available only for partnership/firm/LLP clients.")
entries=[]
for idx,name in enumerate(partner_name):
name=str(name or "").strip()
if not name: continue
try: pct=round(float(profit_share_percent[idx] if idx < len(profit_share_percent) else 0),4)
except Exception: return _go(client.id,financial_year,error=f"Invalid profit-sharing percentage for {name}.")
ledger=str(target_ledger_name[idx] if idx < len(target_ledger_name) else "").strip()
entries.append((name,pct,ledger))
total=round(sum(x[1] for x in entries),4)
if entries and abs(total-100.0)>0.0001:
return _go(client.id,financial_year,error=f"Partner profit-sharing ratio must total 100%. Current total: {total:.4f}%.")
db.execute(delete(AccountingPartnershipProfitShare).where(
AccountingPartnershipProfitShare.tenant_id==scope.tenant_id,
AccountingPartnershipProfitShare.client_id==client.id,
AccountingPartnershipProfitShare.financial_year==financial_year,
))
for name,pct,ledger in entries:
db.add(AccountingPartnershipProfitShare(
tenant_id=scope.tenant_id,client_id=client.id,financial_year=financial_year,
partner_name=name,profit_share_percent=pct,target_ledger_name=ledger,
updated_by_user_id=user.id,
))
db.commit()
return _go(client.id,financial_year,message="Partnership profit-sharing ratio saved. Final profit allocation has been refreshed.")
except Exception as exc:
db.rollback(); return _go(client_id,financial_year,error=str(exc))
finally: db.close()
@router.get("/excel")
def download_excel(request: Request, client_id: int, financial_year: str = ""):
db=CommonSessionLocal()
try:
user,denied=_require_partner(request,db,"accounting.tally.view")
if denied: return denied
client,_,scope=_find_visible_client(db,request,user,client_id)
if not client: return _go(error="Client is not available in the active workspace.")
fy=str(financial_year or _active_financial_year(request)).strip()
report=_load_report(db,request,user,client,scope,fy)
address=", ".join(x for x in [
str(getattr(client,"address_line_1","") or "").strip(),
str(getattr(client,"address_line_2","") or "").strip(),
str(getattr(client,"city","") or "").strip(),
str(getattr(client,"pincode","") or "").strip(),
] if x)
content=build_excel_workbook(
client_name=str(client.client_name or "Client"), address=address,
financial_year=fy, previous_fy=report["previous_fy"],
current_lines=report["current"]["lines"], previous_lines=report["previous"]["lines"],
statements=report["statements"], partnership_shares=report["share_payload"],
)
safe="".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in str(client.client_name or "Client"))
filename=f"{safe}_Financial_Statements_{fy}.xlsx"
return StreamingResponse(
iter([content]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition":f'attachment; filename="{filename}"'},
)
except Exception as exc:
return _go(client_id,financial_year,error=str(exc))
finally: db.close()