373 lines
15 KiB
Python
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(
|
|
"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()
|