Add mirror based trial balance and financial statements
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user