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()