Add mirror based trial balance and financial statements
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""Add mirror-driven financial statement reporting configuration.
|
||||
|
||||
Revision ID: 20260921_financial_statements
|
||||
Revises: f8aac1809dcd
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "20260921_financial_statements"
|
||||
down_revision = "f8aac1809dcd"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"accounting_financial_ledger_mappings",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("ledger_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("statement_code", sa.String(length=20), nullable=False),
|
||||
sa.Column("schedule_code", sa.String(length=60), nullable=False),
|
||||
sa.Column("display_group", sa.String(length=160), nullable=False, server_default=""),
|
||||
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.UniqueConstraint("tenant_id", "client_id", "ledger_name", name="uq_accounting_fs_mapping_client_ledger"),
|
||||
)
|
||||
op.create_index("ix_accounting_financial_ledger_mappings_tenant_id", "accounting_financial_ledger_mappings", ["tenant_id"])
|
||||
op.create_index("ix_accounting_financial_ledger_mappings_client_id", "accounting_financial_ledger_mappings", ["client_id"])
|
||||
op.create_index("ix_accounting_financial_ledger_mappings_ledger_name", "accounting_financial_ledger_mappings", ["ledger_name"])
|
||||
op.create_index("ix_accounting_financial_ledger_mappings_statement_code", "accounting_financial_ledger_mappings", ["statement_code"])
|
||||
op.create_index("ix_accounting_financial_ledger_mappings_schedule_code", "accounting_financial_ledger_mappings", ["schedule_code"])
|
||||
|
||||
op.create_table(
|
||||
"accounting_partnership_profit_shares",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("financial_year", sa.String(length=7), nullable=False),
|
||||
sa.Column("partner_name", sa.String(length=200), nullable=False),
|
||||
sa.Column("profit_share_percent", sa.Numeric(7, 4), nullable=False, server_default="0"),
|
||||
sa.Column("target_ledger_name", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("updated_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("CURRENT_TIMESTAMP")),
|
||||
sa.UniqueConstraint("tenant_id", "client_id", "financial_year", "partner_name", name="uq_accounting_partner_share_client_fy_name"),
|
||||
)
|
||||
op.create_index("ix_accounting_partnership_profit_shares_tenant_id", "accounting_partnership_profit_shares", ["tenant_id"])
|
||||
op.create_index("ix_accounting_partnership_profit_shares_client_id", "accounting_partnership_profit_shares", ["client_id"])
|
||||
op.create_index("ix_accounting_partnership_profit_shares_financial_year", "accounting_partnership_profit_shares", ["financial_year"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_accounting_partnership_profit_shares_financial_year", table_name="accounting_partnership_profit_shares")
|
||||
op.drop_index("ix_accounting_partnership_profit_shares_client_id", table_name="accounting_partnership_profit_shares")
|
||||
op.drop_index("ix_accounting_partnership_profit_shares_tenant_id", table_name="accounting_partnership_profit_shares")
|
||||
op.drop_table("accounting_partnership_profit_shares")
|
||||
op.drop_index("ix_accounting_financial_ledger_mappings_schedule_code", table_name="accounting_financial_ledger_mappings")
|
||||
op.drop_index("ix_accounting_financial_ledger_mappings_statement_code", table_name="accounting_financial_ledger_mappings")
|
||||
op.drop_index("ix_accounting_financial_ledger_mappings_ledger_name", table_name="accounting_financial_ledger_mappings")
|
||||
op.drop_index("ix_accounting_financial_ledger_mappings_client_id", table_name="accounting_financial_ledger_mappings")
|
||||
op.drop_index("ix_accounting_financial_ledger_mappings_tenant_id", table_name="accounting_financial_ledger_mappings")
|
||||
op.drop_table("accounting_financial_ledger_mappings")
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class AccountingFinancialLedgerMapping(CommonBase):
|
||||
"""Client-specific reporting override for a mirrored Tally ledger.
|
||||
|
||||
The default classifier remains Tally-group driven. Rows in this table are
|
||||
only explicit overrides and therefore remain reusable across financial years.
|
||||
"""
|
||||
|
||||
__tablename__ = "accounting_financial_ledger_mappings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "client_id", "ledger_name", name="uq_accounting_fs_mapping_client_ledger"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
statement_code: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
schedule_code: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
display_group: Mapped[str] = mapped_column(String(160), nullable=False, default="")
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow)
|
||||
|
||||
|
||||
class AccountingPartnershipProfitShare(CommonBase):
|
||||
"""Client/FY partner profit-sharing configuration used only for finalisation."""
|
||||
|
||||
__tablename__ = "accounting_partnership_profit_shares"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "client_id", "financial_year", "partner_name",
|
||||
name="uq_accounting_partner_share_client_fy_name",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
financial_year: Mapped[str] = mapped_column(String(7), nullable=False, index=True)
|
||||
partner_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
profit_share_percent: Mapped[float] = mapped_column(Numeric(7, 4), nullable=False, default=0)
|
||||
target_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow)
|
||||
@@ -0,0 +1,619 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from typing import Any, Iterable
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.modules.accounting.accounting_mirror_service import mirror_query, trial_balance
|
||||
from app.modules.accounting.financial_statements_models import AccountingFinancialLedgerMapping
|
||||
|
||||
|
||||
# Canonical schedules. The labels intentionally follow the conventional
|
||||
# financial-statement presentation seen in the firm's existing statements.
|
||||
SCHEDULES: dict[str, dict[str, Any]] = {
|
||||
"PARTNER_FUNDS": {"statement": "BS", "label": "Partner's / Proprietor's Funds", "bs_side": "LIABILITY", "order": 10},
|
||||
"RESERVES": {"statement": "BS", "label": "Reserves & Surplus", "bs_side": "LIABILITY", "order": 20},
|
||||
"NONCURRENT_BORROWINGS": {"statement": "BS", "label": "Long-Term Borrowings", "bs_side": "LIABILITY", "order": 30},
|
||||
"SHORT_TERM_BORROWINGS": {"statement": "BS", "label": "Short-Term Borrowings", "bs_side": "LIABILITY", "order": 40},
|
||||
"TRADE_PAYABLES": {"statement": "BS", "label": "Trade Payables", "bs_side": "LIABILITY", "order": 50},
|
||||
"STATUTORY_LIABILITIES": {"statement": "BS", "label": "Statutory Liabilities", "bs_side": "LIABILITY", "order": 60},
|
||||
"PROVISIONS": {"statement": "BS", "label": "Short-Term Provisions", "bs_side": "LIABILITY", "order": 70},
|
||||
"OTHER_CURRENT_LIABILITIES": {"statement": "BS", "label": "Other Current Liabilities", "bs_side": "LIABILITY", "order": 80},
|
||||
"PPE": {"statement": "BS", "label": "Property, Plant and Equipment", "bs_side": "ASSET", "order": 110},
|
||||
"INVESTMENTS": {"statement": "BS", "label": "Investments", "bs_side": "ASSET", "order": 120},
|
||||
"INVENTORIES": {"statement": "BS", "label": "Inventories", "bs_side": "ASSET", "order": 130},
|
||||
"LOANS_ADVANCES": {"statement": "BS", "label": "Short-Term Loans and Advances", "bs_side": "ASSET", "order": 140},
|
||||
"TRADE_RECEIVABLES": {"statement": "BS", "label": "Trade Receivables", "bs_side": "ASSET", "order": 150},
|
||||
"CASH_BANK": {"statement": "BS", "label": "Cash and Bank Balances", "bs_side": "ASSET", "order": 160},
|
||||
"OTHER_CURRENT_ASSETS": {"statement": "BS", "label": "Other Current Assets", "bs_side": "ASSET", "order": 170},
|
||||
"MISC_ASSET": {"statement": "BS", "label": "Miscellaneous Expenditure / Other Assets", "bs_side": "ASSET", "order": 180},
|
||||
"REVENUE": {"statement": "PL", "label": "Revenue from operations", "pl_side": "INCOME", "order": 210},
|
||||
"OTHER_INCOME": {"statement": "PL", "label": "Other Income", "pl_side": "INCOME", "order": 220},
|
||||
"PURCHASES": {"statement": "PL", "label": "Purchases", "pl_side": "EXPENSE", "order": 230},
|
||||
"DIRECT_EXPENSES": {"statement": "PL", "label": "Direct Expenses", "pl_side": "EXPENSE", "order": 240},
|
||||
"EMPLOYEE_BENEFITS": {"statement": "PL", "label": "Employee Benefits Expense", "pl_side": "EXPENSE", "order": 250},
|
||||
"FINANCE_COST": {"statement": "PL", "label": "Finance Cost", "pl_side": "EXPENSE", "order": 260},
|
||||
"DEPRECIATION": {"statement": "PL", "label": "Depreciation and Amortization Expense", "pl_side": "EXPENSE", "order": 270},
|
||||
"OTHER_EXPENSES": {"statement": "PL", "label": "Other Expenses", "pl_side": "EXPENSE", "order": 280},
|
||||
"UNMAPPED": {"statement": "UNMAPPED", "label": "Unmapped / Review Required", "order": 999},
|
||||
}
|
||||
|
||||
MAPPING_CHOICES = [(code, meta["label"], meta["statement"]) for code, meta in SCHEDULES.items() if code != "UNMAPPED"]
|
||||
|
||||
|
||||
def _norm(value: Any) -> str:
|
||||
return " ".join(str(value or "").strip().lower().replace("&", "and").split())
|
||||
|
||||
|
||||
def _amount(value: Any) -> float:
|
||||
try:
|
||||
return round(float(value or 0), 2)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _root_group(group_name: str, parents: dict[str, str]) -> str:
|
||||
current = str(group_name or "").strip()
|
||||
if not current:
|
||||
return ""
|
||||
seen: set[str] = set()
|
||||
last = current
|
||||
while current and _norm(current) not in seen:
|
||||
seen.add(_norm(current))
|
||||
last = current
|
||||
parent = parents.get(_norm(current), "")
|
||||
if not parent or _norm(parent) in {"primary", ""}:
|
||||
break
|
||||
current = parent
|
||||
return last
|
||||
|
||||
|
||||
def _keyword_any(text: str, words: Iterable[str]) -> bool:
|
||||
value = _norm(text)
|
||||
return any(_norm(word) in value for word in words)
|
||||
|
||||
|
||||
def classify_default(ledger_name: str, parent_group: str, root_group: str) -> str:
|
||||
ledger = _norm(ledger_name)
|
||||
parent = _norm(parent_group)
|
||||
root = _norm(root_group)
|
||||
combined = f"{ledger} {parent} {root}"
|
||||
|
||||
if root in {"sales accounts", "sales account"}:
|
||||
return "REVENUE"
|
||||
if root in {"purchase accounts", "purchase account"}:
|
||||
return "PURCHASES"
|
||||
if root in {"direct expenses", "direct expense"}:
|
||||
return "DIRECT_EXPENSES"
|
||||
if root in {"direct incomes", "direct income"}:
|
||||
return "REVENUE"
|
||||
if root in {"indirect incomes", "indirect income"}:
|
||||
return "OTHER_INCOME"
|
||||
if root in {"indirect expenses", "indirect expense"}:
|
||||
if _keyword_any(combined, ["salary", "salaries", "wages", "bonus", "staff welfare", "employee", "remuneration"]):
|
||||
return "EMPLOYEE_BENEFITS"
|
||||
if _keyword_any(combined, ["interest", "bank charge", "finance charge", "loan processing", "od interest"]):
|
||||
return "FINANCE_COST"
|
||||
if _keyword_any(combined, ["depreciation", "amortisation", "amortization"]):
|
||||
return "DEPRECIATION"
|
||||
return "OTHER_EXPENSES"
|
||||
|
||||
if root in {"capital account", "capital accounts"}:
|
||||
if "reserve" in combined or "surplus" in combined:
|
||||
return "RESERVES"
|
||||
return "PARTNER_FUNDS"
|
||||
if root in {"loans (liability)", "loans liability"}:
|
||||
if _keyword_any(combined, ["bank od", "overdraft", "cash credit", "cc a/c", "cc account", "working capital"]):
|
||||
return "SHORT_TERM_BORROWINGS"
|
||||
return "NONCURRENT_BORROWINGS"
|
||||
if root in {"current liabilities", "current liability"}:
|
||||
if parent in {"sundry creditors", "sundry creditor"} or "creditor" in combined or "trade payable" in combined:
|
||||
return "TRADE_PAYABLES"
|
||||
if parent in {"duties and taxes", "duties & taxes"} or _keyword_any(combined, ["gst", "tds", "tcs", "pf payable", "esi payable", "tax payable"]):
|
||||
return "STATUTORY_LIABILITIES"
|
||||
if parent == "provisions" or "provision" in combined or "payable" in ledger:
|
||||
return "PROVISIONS"
|
||||
return "OTHER_CURRENT_LIABILITIES"
|
||||
if root in {"fixed assets", "fixed asset"}:
|
||||
return "PPE"
|
||||
if root in {"investments", "investment"}:
|
||||
return "INVESTMENTS"
|
||||
if root in {"current assets", "current asset"}:
|
||||
if parent in {"stock-in-hand", "stock in hand"} or _keyword_any(combined, ["stock in hand", "inventory", "closing stock"]):
|
||||
return "INVENTORIES"
|
||||
if parent in {"sundry debtors", "sundry debtor"} or "debtor" in combined or "trade receivable" in combined:
|
||||
return "TRADE_RECEIVABLES"
|
||||
if parent in {"cash-in-hand", "cash in hand", "bank accounts", "bank account"} or _keyword_any(combined, ["cash in hand", "bank account"]):
|
||||
return "CASH_BANK"
|
||||
if parent in {"loans and advances (asset)", "loans & advances (asset)", "deposits (asset)", "deposits asset"} or _keyword_any(combined, ["deposit", "advance tax", "tds receivable", "tcs receivable", "gst input", "advance"]):
|
||||
return "LOANS_ADVANCES"
|
||||
return "OTHER_CURRENT_ASSETS"
|
||||
if root in {"misc. expenses (asset)", "misc expenses (asset)", "suspense a/c", "suspense account"}:
|
||||
return "MISC_ASSET"
|
||||
if root in {"branch/divisions", "branch / divisions"}:
|
||||
return "OTHER_CURRENT_ASSETS"
|
||||
return "UNMAPPED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerLine:
|
||||
ledger_name: str
|
||||
parent_group: str
|
||||
root_group: str
|
||||
opening_balance: float
|
||||
debit: float
|
||||
credit: float
|
||||
closing_balance: float
|
||||
opening_debit: float
|
||||
opening_credit: float
|
||||
closing_debit: float
|
||||
closing_credit: float
|
||||
schedule_code: str
|
||||
schedule_label: str
|
||||
statement_code: str
|
||||
mapped_by: str
|
||||
|
||||
@property
|
||||
def closing_abs(self) -> float:
|
||||
return abs(self.closing_balance)
|
||||
|
||||
|
||||
def _balance_split(value: float) -> tuple[float, float]:
|
||||
# Tally mirror convention: positive balances are debit, negative are credit.
|
||||
value = _amount(value)
|
||||
return (value if value > 0 else 0.0, abs(value) if value < 0 else 0.0)
|
||||
|
||||
|
||||
def read_overrides(db, *, tenant_id: int, client_id: int) -> dict[str, AccountingFinancialLedgerMapping]:
|
||||
rows = db.execute(
|
||||
select(AccountingFinancialLedgerMapping).where(
|
||||
AccountingFinancialLedgerMapping.tenant_id == int(tenant_id),
|
||||
AccountingFinancialLedgerMapping.client_id == int(client_id),
|
||||
)
|
||||
).scalars().all()
|
||||
return {_norm(row.ledger_name): row for row in rows}
|
||||
|
||||
|
||||
def load_mirror_trial_balance(
|
||||
*, node_code: str, accounting_payload: dict[str, Any], db=None, tenant_id: int = 0, client_id: int = 0
|
||||
) -> dict[str, Any]:
|
||||
tb = trial_balance(node_code=node_code, accounting_payload=accounting_payload, limit=5000)
|
||||
group_rows: list[dict[str, Any]] = []
|
||||
hierarchy_warning = ""
|
||||
try:
|
||||
groups = mirror_query(
|
||||
node_code=node_code,
|
||||
accounting_payload=accounting_payload,
|
||||
query="group_master",
|
||||
filters={"limit": 5000},
|
||||
)
|
||||
group_rows = list(groups.get("rows") or [])
|
||||
except Exception as exc:
|
||||
hierarchy_warning = str(exc)
|
||||
|
||||
parents = {
|
||||
_norm(row.get("name")): str(row.get("parent") or "").strip()
|
||||
for row in group_rows
|
||||
if str(row.get("name") or "").strip()
|
||||
}
|
||||
overrides = read_overrides(db, tenant_id=tenant_id, client_id=client_id) if db is not None else {}
|
||||
|
||||
lines: list[LedgerLine] = []
|
||||
for row in tb.get("rows") or []:
|
||||
name = str(row.get("ledger_name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
parent = str(row.get("parent_group") or "").strip()
|
||||
root = _root_group(parent, parents) if parents else parent
|
||||
override = overrides.get(_norm(name))
|
||||
if override and override.schedule_code in SCHEDULES:
|
||||
code = override.schedule_code
|
||||
mapped_by = "manual"
|
||||
else:
|
||||
code = classify_default(name, parent, root)
|
||||
mapped_by = "tally_group"
|
||||
meta = SCHEDULES.get(code, SCHEDULES["UNMAPPED"])
|
||||
opening = _amount(row.get("opening_balance"))
|
||||
closing = _amount(row.get("closing_balance"))
|
||||
od, oc = _balance_split(opening)
|
||||
cd, cc = _balance_split(closing)
|
||||
lines.append(LedgerLine(
|
||||
ledger_name=name,
|
||||
parent_group=parent,
|
||||
root_group=root,
|
||||
opening_balance=opening,
|
||||
debit=_amount(row.get("debit")),
|
||||
credit=_amount(row.get("credit")),
|
||||
closing_balance=closing,
|
||||
opening_debit=od,
|
||||
opening_credit=oc,
|
||||
closing_debit=cd,
|
||||
closing_credit=cc,
|
||||
schedule_code=code,
|
||||
schedule_label=str(meta.get("label") or code),
|
||||
statement_code=str(meta.get("statement") or "UNMAPPED"),
|
||||
mapped_by=mapped_by,
|
||||
))
|
||||
return {
|
||||
"lines": lines,
|
||||
"group_count": len(group_rows),
|
||||
"hierarchy_warning": hierarchy_warning,
|
||||
"count": len(lines),
|
||||
}
|
||||
|
||||
|
||||
def previous_financial_year(financial_year: str) -> str:
|
||||
start = int(str(financial_year).split("-")[0])
|
||||
return f"{start - 1}-{str(start)[-2:]}"
|
||||
|
||||
|
||||
def _schedule_totals(lines: list[LedgerLine]) -> dict[str, float]:
|
||||
totals: dict[str, float] = defaultdict(float)
|
||||
for line in lines:
|
||||
totals[line.schedule_code] += line.closing_abs
|
||||
return {k: round(v, 2) for k, v in totals.items()}
|
||||
|
||||
|
||||
def _inventory_values(lines: list[LedgerLine]) -> tuple[float, float]:
|
||||
opening = sum(abs(x.opening_balance) for x in lines if x.schedule_code == "INVENTORIES")
|
||||
closing = sum(abs(x.closing_balance) for x in lines if x.schedule_code == "INVENTORIES")
|
||||
return round(opening, 2), round(closing, 2)
|
||||
|
||||
|
||||
def build_financial_statements(
|
||||
*, current_lines: list[LedgerLine], previous_lines: list[LedgerLine] | None = None,
|
||||
partnership_shares: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
previous_lines = previous_lines or []
|
||||
current_totals = _schedule_totals(current_lines)
|
||||
previous_totals = _schedule_totals(previous_lines)
|
||||
|
||||
def pl_values(lines: list[LedgerLine]) -> dict[str, float]:
|
||||
totals = _schedule_totals(lines)
|
||||
opening_stock, closing_stock = _inventory_values(lines)
|
||||
purchases = totals.get("PURCHASES", 0.0)
|
||||
cost_of_goods = round(opening_stock + purchases - closing_stock, 2)
|
||||
revenue = totals.get("REVENUE", 0.0)
|
||||
other_income = totals.get("OTHER_INCOME", 0.0)
|
||||
direct = totals.get("DIRECT_EXPENSES", 0.0)
|
||||
employee = totals.get("EMPLOYEE_BENEFITS", 0.0)
|
||||
finance = totals.get("FINANCE_COST", 0.0)
|
||||
depreciation = totals.get("DEPRECIATION", 0.0)
|
||||
other_exp = totals.get("OTHER_EXPENSES", 0.0)
|
||||
total_income = round(revenue + other_income, 2)
|
||||
total_expenses = round(cost_of_goods + direct + employee + finance + depreciation + other_exp, 2)
|
||||
profit = round(total_income - total_expenses, 2)
|
||||
return {
|
||||
"REVENUE": revenue, "OTHER_INCOME": other_income,
|
||||
"OPENING_STOCK": opening_stock, "PURCHASES": purchases, "CLOSING_STOCK": closing_stock,
|
||||
"COST_OF_GOODS_SOLD": cost_of_goods, "DIRECT_EXPENSES": direct,
|
||||
"EMPLOYEE_BENEFITS": employee, "FINANCE_COST": finance,
|
||||
"DEPRECIATION": depreciation, "OTHER_EXPENSES": other_exp,
|
||||
"TOTAL_INCOME": total_income, "TOTAL_EXPENSES": total_expenses, "PROFIT": profit,
|
||||
}
|
||||
|
||||
current_pl = pl_values(current_lines)
|
||||
previous_pl = pl_values(previous_lines)
|
||||
|
||||
allocations: list[dict[str, Any]] = []
|
||||
shares = partnership_shares or []
|
||||
ratio_total = round(sum(float(x.get("profit_share_percent") or 0) for x in shares), 4)
|
||||
if shares and abs(ratio_total - 100.0) < 0.0001:
|
||||
remaining = current_pl["PROFIT"]
|
||||
for idx, share in enumerate(shares):
|
||||
if idx == len(shares) - 1:
|
||||
amount = round(remaining, 2)
|
||||
else:
|
||||
amount = round(current_pl["PROFIT"] * float(share.get("profit_share_percent") or 0) / 100.0, 2)
|
||||
remaining = round(remaining - amount, 2)
|
||||
allocations.append({**share, "amount": amount})
|
||||
|
||||
# The mirror may contain either pre-appropriation or already-finalised capital balances.
|
||||
# Do not blindly add profit to capital: first compare the raw Balance Sheet gap with
|
||||
# the P&L result. If Assets - Liabilities equals current-year profit/loss, the books
|
||||
# are pre-appropriation and the ERP adds that result to Partner/Proprietor Funds.
|
||||
# If the Balance Sheet already balances, the profit is already reflected in the
|
||||
# source capital/current accounts and no duplicate adjustment is posted.
|
||||
allocation_total = round(sum(x["amount"] for x in allocations), 2)
|
||||
liability_codes = [k for k,v in SCHEDULES.items() if v.get("statement") == "BS" and v.get("bs_side") == "LIABILITY"]
|
||||
asset_codes = [k for k,v in SCHEDULES.items() if v.get("statement") == "BS" and v.get("bs_side") == "ASSET"]
|
||||
|
||||
current_bs = dict(current_totals)
|
||||
raw_current_liabilities = round(sum(current_bs.get(k,0.0) for k in liability_codes),2)
|
||||
current_assets = round(sum(current_bs.get(k,0.0) for k in asset_codes),2)
|
||||
current_gap = round(current_assets - raw_current_liabilities, 2)
|
||||
current_profit = round(current_pl["PROFIT"], 2)
|
||||
tolerance = max(1.0, abs(current_profit) * 0.0001)
|
||||
profit_transfer_applied = False
|
||||
profit_transfer_status = "already_reflected" if abs(current_gap) <= tolerance else "review_required"
|
||||
if abs(current_gap - current_profit) <= tolerance:
|
||||
current_bs["PARTNER_FUNDS"] = round(current_bs.get("PARTNER_FUNDS", 0.0) + current_profit, 2)
|
||||
profit_transfer_applied = True
|
||||
profit_transfer_status = "erp_finalisation_adjustment"
|
||||
current_liabilities = round(sum(current_bs.get(k,0.0) for k in liability_codes),2)
|
||||
|
||||
previous_bs = dict(previous_totals)
|
||||
raw_previous_liabilities = round(sum(previous_bs.get(k,0.0) for k in liability_codes),2)
|
||||
previous_assets = round(sum(previous_bs.get(k,0.0) for k in asset_codes),2)
|
||||
previous_gap = round(previous_assets - raw_previous_liabilities, 2)
|
||||
previous_profit = round(previous_pl["PROFIT"], 2)
|
||||
previous_tolerance = max(1.0, abs(previous_profit) * 0.0001)
|
||||
if abs(previous_gap - previous_profit) <= previous_tolerance:
|
||||
previous_bs["PARTNER_FUNDS"] = round(previous_bs.get("PARTNER_FUNDS", 0.0) + previous_profit, 2)
|
||||
previous_liabilities = round(sum(previous_bs.get(k,0.0) for k in liability_codes),2)
|
||||
|
||||
return {
|
||||
"current_totals": current_totals,
|
||||
"previous_totals": previous_totals,
|
||||
"previous_bs": previous_bs,
|
||||
"current_pl": current_pl,
|
||||
"previous_pl": previous_pl,
|
||||
"current_bs": current_bs,
|
||||
"current_liabilities": current_liabilities,
|
||||
"current_assets": current_assets,
|
||||
"previous_liabilities": previous_liabilities,
|
||||
"previous_assets": previous_assets,
|
||||
"partnership_allocations": allocations,
|
||||
"profit_share_total": ratio_total,
|
||||
"profit_transfer_applied": profit_transfer_applied,
|
||||
"profit_transfer_status": profit_transfer_status,
|
||||
"balance_sheet_difference": round(current_assets - current_liabilities, 2),
|
||||
"unmapped": [x for x in current_lines if x.schedule_code == "UNMAPPED"],
|
||||
}
|
||||
|
||||
|
||||
THIN = Side(style="thin", color="000000")
|
||||
MEDIUM = Side(style="medium", color="000000")
|
||||
DOUBLE = Side(style="double", color="000000")
|
||||
|
||||
|
||||
def _set_page(ws, *, landscape: bool = False, repeat_rows: str | None = None) -> None:
|
||||
ws.sheet_view.showGridLines = False
|
||||
ws.freeze_panes = "A5"
|
||||
ws.page_setup.orientation = "landscape" if landscape else "portrait"
|
||||
ws.page_setup.paperSize = ws.PAPERSIZE_A4
|
||||
ws.page_setup.fitToWidth = 1
|
||||
ws.page_setup.fitToHeight = 0
|
||||
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
||||
ws.print_options.horizontalCentered = True
|
||||
ws.page_margins.left = 0.25
|
||||
ws.page_margins.right = 0.25
|
||||
ws.page_margins.top = 0.35
|
||||
ws.page_margins.bottom = 0.35
|
||||
if repeat_rows:
|
||||
ws.print_title_rows = repeat_rows
|
||||
|
||||
|
||||
def _title(ws, client_name: str, address: str, title: str, end_date: str, cols: int = 4) -> int:
|
||||
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=cols)
|
||||
ws.cell(1,1,client_name).font = Font(bold=True, size=11)
|
||||
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=cols)
|
||||
ws.cell(2,1,address).font = Font(size=9)
|
||||
ws.merge_cells(start_row=3, start_column=1, end_row=3, end_column=cols)
|
||||
ws.cell(3,1,f"{title} for the Year Ended {end_date}").font = Font(bold=True, underline="single", size=10)
|
||||
return 5
|
||||
|
||||
|
||||
def _money(cell, value: float) -> None:
|
||||
cell.value = round(float(value or 0),2)
|
||||
cell.number_format = '#,##0;[Red](#,##0);-'
|
||||
cell.alignment = Alignment(horizontal="right")
|
||||
|
||||
|
||||
def _apply_table_border(ws, min_row: int, max_row: int, min_col: int, max_col: int, outer_medium: bool = True) -> None:
|
||||
for row in ws.iter_rows(min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col):
|
||||
for cell in row:
|
||||
left = MEDIUM if outer_medium and cell.column == min_col else THIN
|
||||
right = MEDIUM if outer_medium and cell.column == max_col else THIN
|
||||
top = MEDIUM if outer_medium and cell.row == min_row else THIN
|
||||
bottom = MEDIUM if outer_medium and cell.row == max_row else THIN
|
||||
cell.border = Border(left=left,right=right,top=top,bottom=bottom)
|
||||
|
||||
|
||||
def _header_row(ws, row: int, labels: list[str]) -> None:
|
||||
for col, label in enumerate(labels,1):
|
||||
c=ws.cell(row,col,label)
|
||||
c.font=Font(bold=True,size=9)
|
||||
c.alignment=Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
c.fill=PatternFill("solid", fgColor="F2F2F2")
|
||||
c.border=Border(top=MEDIUM,bottom=MEDIUM,left=THIN,right=THIN)
|
||||
|
||||
|
||||
def _schedule_sheet_name(note_no: int, label: str) -> str:
|
||||
clean = ''.join(ch for ch in label if ch not in '[]:*?/\\')
|
||||
return f"N{note_no:02d} {clean}"[:31]
|
||||
|
||||
|
||||
def build_excel_workbook(
|
||||
*, client_name: str, address: str, financial_year: str, previous_fy: str,
|
||||
current_lines: list[LedgerLine], previous_lines: list[LedgerLine],
|
||||
statements: dict[str, Any], partnership_shares: list[dict[str, Any]] | None = None,
|
||||
) -> bytes:
|
||||
wb=Workbook()
|
||||
wb.remove(wb.active)
|
||||
start_year=int(financial_year.split('-')[0])
|
||||
current_end=f"31st March {start_year+1}"
|
||||
previous_end=f"31st March {start_year}"
|
||||
|
||||
schedule_codes=[]
|
||||
for code,meta in sorted(SCHEDULES.items(), key=lambda kv:int(kv[1].get('order',999))):
|
||||
if code=="UNMAPPED":
|
||||
continue
|
||||
if any(x.schedule_code==code for x in current_lines) or any(x.schedule_code==code for x in previous_lines):
|
||||
schedule_codes.append(code)
|
||||
note_map={code:i+1 for i,code in enumerate(schedule_codes)}
|
||||
|
||||
# Trial Balance
|
||||
ws=wb.create_sheet("Trial Balance")
|
||||
_set_page(ws, landscape=True, repeat_rows="1:5")
|
||||
_title(ws,client_name,address,"Trial Balance",current_end,8)
|
||||
_header_row(ws,5,["Ledger","Tally Group","Opening Dr","Opening Cr","Current Dr","Current Cr","Closing Dr","Closing Cr"])
|
||||
r=6
|
||||
for line in sorted(current_lines,key=lambda x:(x.root_group.lower(),x.parent_group.lower(),x.ledger_name.lower())):
|
||||
ws.cell(r,1,line.ledger_name); ws.cell(r,2,line.parent_group)
|
||||
for col,val in [(3,line.opening_debit),(4,line.opening_credit),(5,line.debit),(6,line.credit),(7,line.closing_debit),(8,line.closing_credit)]: _money(ws.cell(r,col),val)
|
||||
r+=1
|
||||
ws.cell(r,1,"Total").font=Font(bold=True)
|
||||
for col in range(3,9):
|
||||
_money(ws.cell(r,col),sum(float(ws.cell(rr,col).value or 0) for rr in range(6,r)))
|
||||
ws.cell(r,col).font=Font(bold=True); ws.cell(r,col).border=Border(top=DOUBLE,bottom=DOUBLE)
|
||||
_apply_table_border(ws,5,r,1,8)
|
||||
widths=[34,24,14,14,14,14,14,14]
|
||||
for i,w in enumerate(widths,1): ws.column_dimensions[get_column_letter(i)].width=w
|
||||
|
||||
# Balance Sheet in sample format
|
||||
ws=wb.create_sheet("Balance Sheet")
|
||||
_set_page(ws, repeat_rows="1:5")
|
||||
_title(ws,client_name,address,"Statement of Balance Sheet",current_end,4)
|
||||
_header_row(ws,5,["Particulars","Note",current_end,previous_end])
|
||||
r=6
|
||||
ws.cell(r,1,"I EQUITY AND LIABILITIES").font=Font(bold=True); r+=1
|
||||
liability_codes=[c for c in schedule_codes if SCHEDULES[c].get("bs_side")=="LIABILITY"]
|
||||
for code in liability_codes:
|
||||
meta=SCHEDULES[code]; ws.cell(r,1,meta["label"]); ws.cell(r,2,note_map[code])
|
||||
ws.cell(r,2).hyperlink=f"#'{_schedule_sheet_name(note_map[code],meta['label'])}'!A1"
|
||||
_money(ws.cell(r,3),statements["current_bs"].get(code,0)); _money(ws.cell(r,4),statements.get("previous_bs", statements["previous_totals"]).get(code,0)); r+=1
|
||||
ws.cell(r,1,"Total Liabilities").font=Font(bold=True)
|
||||
_money(ws.cell(r,3),statements["current_liabilities"]); _money(ws.cell(r,4),statements["previous_liabilities"])
|
||||
for c in range(1,5): ws.cell(r,c).border=Border(top=DOUBLE,bottom=DOUBLE)
|
||||
r+=2; ws.cell(r,1,"II ASSETS").font=Font(bold=True); r+=1
|
||||
asset_codes=[c for c in schedule_codes if SCHEDULES[c].get("bs_side")=="ASSET"]
|
||||
for code in asset_codes:
|
||||
meta=SCHEDULES[code]; ws.cell(r,1,meta["label"]); ws.cell(r,2,note_map[code])
|
||||
ws.cell(r,2).hyperlink=f"#'{_schedule_sheet_name(note_map[code],meta['label'])}'!A1"
|
||||
_money(ws.cell(r,3),statements["current_bs"].get(code,0)); _money(ws.cell(r,4),statements.get("previous_bs", statements["previous_totals"]).get(code,0)); r+=1
|
||||
ws.cell(r,1,"Total Assets").font=Font(bold=True)
|
||||
_money(ws.cell(r,3),statements["current_assets"]); _money(ws.cell(r,4),statements["previous_assets"])
|
||||
for c in range(1,5): ws.cell(r,c).border=Border(top=DOUBLE,bottom=DOUBLE)
|
||||
r+=2; ws.merge_cells(start_row=r,start_column=1,end_row=r,end_column=4)
|
||||
ws.cell(r,1,"The accompanying notes are an integral part of the financial statements").font=Font(italic=True,size=8)
|
||||
_apply_table_border(ws,5,r-2,1,4)
|
||||
for col,w in {1:46,2:9,3:18,4:18}.items(): ws.column_dimensions[get_column_letter(col)].width=w
|
||||
|
||||
# Profit and Loss
|
||||
ws=wb.create_sheet("Profit and Loss")
|
||||
_set_page(ws, repeat_rows="1:5")
|
||||
_title(ws,client_name,address,"Statement of Profit and Loss",current_end,4)
|
||||
_header_row(ws,5,["Particulars","Note",current_end,previous_end])
|
||||
cp=statements["current_pl"]; pp=statements["previous_pl"]
|
||||
rows=[
|
||||
("I", "Revenue from operations", "REVENUE", "REVENUE"),
|
||||
("II", "Other Income", "OTHER_INCOME", "OTHER_INCOME"),
|
||||
("III", "Total Income (I+II)", None, "TOTAL_INCOME"),
|
||||
("IV", "Expenses:", None, None),
|
||||
("(a)", "Cost of goods sold", None, "COST_OF_GOODS_SOLD"),
|
||||
("(b)", "Direct Expenses", "DIRECT_EXPENSES", "DIRECT_EXPENSES"),
|
||||
("(c)", "Employee benefits expense", "EMPLOYEE_BENEFITS", "EMPLOYEE_BENEFITS"),
|
||||
("(d)", "Finance Cost", "FINANCE_COST", "FINANCE_COST"),
|
||||
("(e)", "Depreciation and amortization expense", "DEPRECIATION", "DEPRECIATION"),
|
||||
("(f)", "Other expenses", "OTHER_EXPENSES", "OTHER_EXPENSES"),
|
||||
("", "Total expenses", None, "TOTAL_EXPENSES"),
|
||||
("V", "Profit/(loss) before exceptional and extraordinary items (III-IV)", None, "PROFIT"),
|
||||
("VI", "Exceptional items", None, None),
|
||||
("VII", "Profit/(loss) before extraordinary items (V-VI)", None, "PROFIT"),
|
||||
("VIII", "Extraordinary items", None, None),
|
||||
("IX", "Profit before tax (VII-VIII)", None, "PROFIT"),
|
||||
("X", "Profit/(Loss) for the year", None, "PROFIT"),
|
||||
]
|
||||
r=6
|
||||
for roman,label,note_code,key in rows:
|
||||
ws.cell(r,1,f"{roman} {label}".strip())
|
||||
if roman in {"III","IV","V","VII","IX","X"} or label=="Total expenses": ws.cell(r,1).font=Font(bold=True)
|
||||
if note_code and note_code in note_map:
|
||||
ws.cell(r,2,note_map[note_code]); ws.cell(r,2).hyperlink=f"#'{_schedule_sheet_name(note_map[note_code],SCHEDULES[note_code]['label'])}'!A1"
|
||||
if key:
|
||||
_money(ws.cell(r,3),cp.get(key,0)); _money(ws.cell(r,4),pp.get(key,0))
|
||||
if roman in {"III","V","VII","IX","X"} or label=="Total expenses":
|
||||
for c in range(1,5): ws.cell(r,c).border=Border(top=THIN,bottom=DOUBLE)
|
||||
r+=1
|
||||
r+=1; ws.merge_cells(start_row=r,start_column=1,end_row=r,end_column=4)
|
||||
ws.cell(r,1,"The accompanying notes are an integral part of the financial statements").font=Font(italic=True,size=8)
|
||||
_apply_table_border(ws,5,r-2,1,4)
|
||||
for col,w in {1:56,2:9,3:18,4:18}.items(): ws.column_dimensions[get_column_letter(col)].width=w
|
||||
|
||||
# Cost of goods sold schedule (opening inventory + purchases - closing inventory).
|
||||
ws=wb.create_sheet("Cost of Goods Sold")
|
||||
_set_page(ws)
|
||||
_title(ws,client_name,address,"Note - Cost of Goods Sold",current_end,3)
|
||||
_header_row(ws,5,["Particulars",current_end,previous_end])
|
||||
cpl=statements["current_pl"]; ppl=statements["previous_pl"]
|
||||
cogs_rows=[
|
||||
("Inventory at the beginning of the year","OPENING_STOCK"),
|
||||
("Add: Purchases during the year","PURCHASES"),
|
||||
("Less: Inventory at the end of the year","CLOSING_STOCK"),
|
||||
("Cost of goods sold","COST_OF_GOODS_SOLD"),
|
||||
]
|
||||
r=6
|
||||
for label,key in cogs_rows:
|
||||
ws.cell(r,1,label); _money(ws.cell(r,2),cpl.get(key,0)); _money(ws.cell(r,3),ppl.get(key,0))
|
||||
if key=="COST_OF_GOODS_SOLD":
|
||||
ws.cell(r,1).font=Font(bold=True)
|
||||
for c in range(1,4): ws.cell(r,c).border=Border(top=DOUBLE,bottom=DOUBLE)
|
||||
r+=1
|
||||
_apply_table_border(ws,5,r-1,1,3)
|
||||
ws.column_dimensions['A'].width=52; ws.column_dimensions['B'].width=18; ws.column_dimensions['C'].width=18
|
||||
|
||||
# Notes/schedules
|
||||
prev_by_name={_norm(x.ledger_name):x for x in previous_lines}
|
||||
for code in schedule_codes:
|
||||
meta=SCHEDULES[code]; note=note_map[code]
|
||||
ws=wb.create_sheet(_schedule_sheet_name(note,meta["label"]))
|
||||
_set_page(ws, repeat_rows="1:5")
|
||||
_title(ws,client_name,address,f"Note - {note}: {meta['label']}",current_end,3)
|
||||
_header_row(ws,5,["Particulars",current_end,previous_end])
|
||||
cur=[x for x in current_lines if x.schedule_code==code]
|
||||
r=6
|
||||
for line in sorted(cur,key=lambda x:x.ledger_name.lower()):
|
||||
ws.cell(r,1,line.ledger_name); _money(ws.cell(r,2),line.closing_abs)
|
||||
old=prev_by_name.get(_norm(line.ledger_name)); _money(ws.cell(r,3),old.closing_abs if old else 0); r+=1
|
||||
if code=="PARTNER_FUNDS" and statements.get("profit_transfer_applied"):
|
||||
if statements.get("partnership_allocations"):
|
||||
for alloc in statements["partnership_allocations"]:
|
||||
ws.cell(r,1,f"Add: Share of Profit / (Loss) - {alloc.get('partner_name')}")
|
||||
_money(ws.cell(r,2),alloc.get("amount") or 0); _money(ws.cell(r,3),0); r+=1
|
||||
else:
|
||||
ws.cell(r,1,"Add: Current Year Profit / (Loss)")
|
||||
_money(ws.cell(r,2),statements.get("current_pl",{}).get("PROFIT",0)); _money(ws.cell(r,3),0); r+=1
|
||||
ws.cell(r,1,"Total").font=Font(bold=True)
|
||||
_money(ws.cell(r,2),statements["current_bs"].get(code,statements["current_totals"].get(code,0)))
|
||||
_money(ws.cell(r,3),statements.get("previous_bs", statements["previous_totals"]).get(code,0))
|
||||
for c in range(1,4): ws.cell(r,c).border=Border(top=DOUBLE,bottom=DOUBLE)
|
||||
_apply_table_border(ws,5,r,1,3)
|
||||
ws.column_dimensions['A'].width=52; ws.column_dimensions['B'].width=18; ws.column_dimensions['C'].width=18
|
||||
|
||||
if partnership_shares:
|
||||
ws=wb.create_sheet("Partner Profit Allocation")
|
||||
_set_page(ws)
|
||||
_title(ws,client_name,address,"Partnership Profit Allocation",current_end,4)
|
||||
_header_row(ws,5,["Partner","Profit Share %","Target Capital / Current Ledger","Share of Profit / (Loss)"])
|
||||
alloc_by_name={_norm(x.get('partner_name')):x for x in statements.get('partnership_allocations') or []}
|
||||
r=6
|
||||
for share in partnership_shares:
|
||||
ws.cell(r,1,share.get('partner_name')); ws.cell(r,2,float(share.get('profit_share_percent') or 0)); ws.cell(r,2).number_format='0.00%'
|
||||
# Value stored as percent e.g. 20; Excel percentage needs /100
|
||||
ws.cell(r,2).value=float(share.get('profit_share_percent') or 0)/100.0
|
||||
ws.cell(r,3,share.get('target_ledger_name') or '')
|
||||
alloc=alloc_by_name.get(_norm(share.get('partner_name'))); _money(ws.cell(r,4),(alloc or {}).get('amount') or 0); r+=1
|
||||
ws.cell(r,1,"Total").font=Font(bold=True); ws.cell(r,2,sum(float(x.get('profit_share_percent') or 0) for x in partnership_shares)/100.0); ws.cell(r,2).number_format='0.00%'
|
||||
_money(ws.cell(r,4),sum(float((alloc_by_name.get(_norm(x.get('partner_name'))) or {}).get('amount') or 0) for x in partnership_shares))
|
||||
_apply_table_border(ws,5,r,1,4)
|
||||
for col,w in {1:32,2:16,3:38,4:20}.items(): ws.column_dimensions[get_column_letter(col)].width=w
|
||||
|
||||
if statements.get("unmapped"):
|
||||
ws=wb.create_sheet("Unmapped Ledgers")
|
||||
_set_page(ws, landscape=True)
|
||||
_title(ws,client_name,address,"Unmapped Ledgers - Review Required",current_end,5)
|
||||
_header_row(ws,5,["Ledger","Immediate Tally Group","Resolved Root Group","Closing Debit","Closing Credit"])
|
||||
r=6
|
||||
for line in statements["unmapped"]:
|
||||
ws.cell(r,1,line.ledger_name); ws.cell(r,2,line.parent_group); ws.cell(r,3,line.root_group); _money(ws.cell(r,4),line.closing_debit); _money(ws.cell(r,5),line.closing_credit); r+=1
|
||||
_apply_table_border(ws,5,r-1,1,5)
|
||||
for col,w in {1:35,2:28,3:28,4:18,5:18}.items(): ws.column_dimensions[get_column_letter(col)].width=w
|
||||
|
||||
bio=BytesIO(); wb.save(bio); return bio.getvalue()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,117 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Trial Balance & Financial Statements</h1>
|
||||
<p class="mt-1 max-w-4xl text-sm text-slate-500">Generate Trial Balance, Profit & Loss Account, Balance Sheet and ledger schedules directly from the registered Client/FY Accounting Mirror. The mirror remains read-only.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally Accounting</a>
|
||||
{% if selected_client and report %}<a href="/tools/accounting/financial-statements/excel?client_id={{ selected_client.id }}&financial_year={{ financial_year }}" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Download Excel</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
|
||||
{% if command_error %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ command_error }}</div>{% endif %}
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<form method="get" class="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px_auto] md:items-end">
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">ERP Client</span>
|
||||
<select name="client_id" class="mt-2 w-full rounded-xl border-slate-300 text-sm">
|
||||
<option value="">Select client</option>
|
||||
{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and client.id == selected_client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">Financial Year</span>
|
||||
<select name="financial_year" class="mt-2 w-full rounded-xl border-slate-300 text-sm">
|
||||
{% for fy in financial_year_options %}<option value="{{ fy }}" {% if fy == financial_year %}selected{% endif %}>FY {{ fy }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Load from Mirror</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if selected_client and report %}
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Mirror FY</div><div class="mt-2 text-lg font-semibold text-slate-900">{{ financial_year }}</div><div class="mt-1 text-xs text-slate-500">Version {{ report.current_payload.mirror_version_no or report.current_payload.version_no or '-' }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Ledgers</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ report.current.count }}</div><div class="mt-1 text-xs text-slate-500">{{ report.current.group_count }} Tally groups loaded</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Previous Year</div><div class="mt-2 text-lg font-semibold {% if previous_available %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ previous_fy }}</div><div class="mt-1 text-xs text-slate-500">{{ 'Comparative mirror loaded' if previous_available else 'No registered comparative mirror' }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Mapping Review</div><div class="mt-2 text-2xl font-semibold {% if statements.unmapped|length == 0 %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ statements.unmapped|length }}</div><div class="mt-1 text-xs text-slate-500">Unmapped ledgers</div></div>
|
||||
</section>
|
||||
|
||||
{% if report.current.hierarchy_warning %}
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">The mirror Trial Balance loaded, but the Local Agent could not return the full Tally group hierarchy. Update the Local Agent to the latest version for automatic custom-group resolution. Immediate parent-group classification is still being used.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if statements.balance_sheet_difference|abs > 1 %}
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">Balance Sheet review required: Assets and Equity/Liabilities differ by ₹ {{ '{:,.2f}'.format(statements.balance_sheet_difference) }} after mapped finalisation. Review unmapped or incorrectly classified ledgers before using the statements as final.</div>
|
||||
{% elif statements.profit_transfer_applied %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Current-year profit/loss was not yet reflected in the source capital balances, so the ERP applied a read-only finalisation transfer for this financial statement view. The Accounting Mirror was not changed.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">Balance Sheet</h2><p class="text-xs text-slate-500">Presentation follows the ruled comparative format used in your existing statements.</p></div>
|
||||
<div class="overflow-x-auto p-5">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead><tr class="bg-slate-50"><th class="border border-slate-400 px-3 py-2 text-left">Particulars</th><th class="border border-slate-400 px-3 py-2">Note</th><th class="border border-slate-400 px-3 py-2 text-right">31 Mar {{ financial_year[:4]|int + 1 }}</th><th class="border border-slate-400 px-3 py-2 text-right">31 Mar {{ financial_year[:4] }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" class="border-x border-slate-400 px-3 py-2 font-bold">EQUITY AND LIABILITIES</td></tr>
|
||||
{% for code, meta in schedules.items() if meta.statement == 'BS' and meta.bs_side == 'LIABILITY' and (statements.current_bs.get(code,0) or statements.previous_bs.get(code,0)) %}
|
||||
<tr><td class="border-x border-slate-300 px-3 py-1.5">{{ meta.label }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-center">{{ loop.index }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2">Total Liabilities</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.current_liabilities) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.previous_liabilities) }}</td></tr>
|
||||
<tr><td colspan="4" class="border-x border-slate-400 px-3 py-2 font-bold">ASSETS</td></tr>
|
||||
{% for code, meta in schedules.items() if meta.statement == 'BS' and meta.bs_side == 'ASSET' and (statements.current_bs.get(code,0) or statements.previous_bs.get(code,0)) %}
|
||||
<tr><td class="border-x border-slate-300 px-3 py-1.5">{{ meta.label }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-center">-</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2">Total Assets</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.current_assets) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.previous_assets) }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">Profit & Loss Account</h2><p class="text-xs text-slate-500">Generated from mapped mirror ledgers; inventory movement is used for cost of goods sold.</p></div>
|
||||
<div class="overflow-x-auto p-5">
|
||||
{% set cp=statements.current_pl %}{% set pp=statements.previous_pl %}
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead><tr class="bg-slate-50"><th class="border border-slate-400 px-3 py-2 text-left">Particulars</th><th class="border border-slate-400 px-3 py-2 text-right">Current FY</th><th class="border border-slate-400 px-3 py-2 text-right">Previous FY</th></tr></thead>
|
||||
<tbody>
|
||||
{% for label,key in [('Revenue from operations','REVENUE'),('Other Income','OTHER_INCOME'),('Total Income','TOTAL_INCOME'),('Cost of goods sold','COST_OF_GOODS_SOLD'),('Direct Expenses','DIRECT_EXPENSES'),('Employee benefits expense','EMPLOYEE_BENEFITS'),('Finance Cost','FINANCE_COST'),('Depreciation and amortization expense','DEPRECIATION'),('Other expenses','OTHER_EXPENSES'),('Total expenses','TOTAL_EXPENSES'),('Profit/(Loss) for the year','PROFIT')] %}
|
||||
<tr class="{% if key in ['TOTAL_INCOME','TOTAL_EXPENSES','PROFIT'] %}font-bold{% endif %}"><td class="border border-slate-300 px-3 py-1.5">{{ label }}</td><td class="border border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(cp.get(key,0)) }}</td><td class="border border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(pp.get(key,0)) }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if is_partnership %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="font-semibold text-slate-900">Partnership Profit Allocation</h2><p class="mt-1 text-sm text-slate-500">Ratios are Client/FY-specific. When they total 100%, the year's profit/loss is allocated to partner funds for the final Balance Sheet without changing the mirror.</p></div><div class="text-sm font-semibold {% if statements.profit_share_total == 100 %}text-emerald-700{% else %}text-amber-700{% endif %}">Configured ratio: {{ '%.2f'|format(statements.profit_share_total) }}%</div></div>
|
||||
<form method="post" action="/tools/accounting/financial-statements/partnership" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="financial_year" value="{{ financial_year }}">
|
||||
<div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50"><th class="px-3 py-2 text-left">Partner</th><th class="px-3 py-2 text-right">Profit Share %</th><th class="px-3 py-2 text-left">Capital / Current Ledger (optional)</th></tr></thead><tbody>
|
||||
{% for row in partner_rows %}<tr><td class="px-3 py-2"><input name="partner_name" value="{{ row.partner_name }}" class="w-full rounded-lg border-slate-300"></td><td class="px-3 py-2"><input name="profit_share_percent" type="number" step="0.0001" value="{{ row.profit_share_percent }}" class="w-32 rounded-lg border-slate-300 text-right"></td><td class="px-3 py-2"><input name="target_ledger_name" value="{{ row.target_ledger_name }}" class="w-full rounded-lg border-slate-300" placeholder="e.g. Partner Current Account"></td></tr>{% endfor %}
|
||||
{% if not partner_rows %}{% for i in range(2) %}<tr><td class="px-3 py-2"><input name="partner_name" class="w-full rounded-lg border-slate-300"></td><td class="px-3 py-2"><input name="profit_share_percent" type="number" step="0.0001" class="w-32 rounded-lg border-slate-300 text-right"></td><td class="px-3 py-2"><input name="target_ledger_name" class="w-full rounded-lg border-slate-300"></td></tr>{% endfor %}{% endif %}
|
||||
</tbody></table></div>
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Save Ratio & Recalculate</button>
|
||||
</form>
|
||||
{% if statements.partnership_allocations %}<div class="mt-4 grid gap-2 md:grid-cols-2 xl:grid-cols-5">{% for row in statements.partnership_allocations %}<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs text-slate-500">{{ row.partner_name }} · {{ row.profit_share_percent }}%</div><div class="mt-1 font-semibold text-slate-900">₹ {{ '{:,.0f}'.format(row.amount) }}</div></div>{% endfor %}</div>{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="flex items-center justify-between gap-3"><div><h2 class="font-semibold text-slate-900">Trial Balance & Ledger Mapping</h2><p class="mt-1 text-sm text-slate-500">Default classification follows the complete Tally group hierarchy. Use an override only when a ledger needs a different financial-statement schedule.</p></div></div>
|
||||
<div class="mt-4 overflow-x-auto"><table class="min-w-full text-xs"><thead><tr class="bg-slate-50"><th class="px-2 py-2 text-left">Ledger</th><th class="px-2 py-2 text-left">Tally Group</th><th class="px-2 py-2 text-left">Root Group</th><th class="px-2 py-2 text-left">Schedule</th><th class="px-2 py-2 text-right">Closing Dr</th><th class="px-2 py-2 text-right">Closing Cr</th><th class="px-2 py-2">Mapping</th></tr></thead><tbody class="divide-y divide-slate-100">
|
||||
{% for line in current_lines %}<tr class="{% if line.schedule_code == 'UNMAPPED' %}bg-amber-50{% endif %}"><td class="px-2 py-2 font-medium">{{ line.ledger_name }}</td><td class="px-2 py-2">{{ line.parent_group }}</td><td class="px-2 py-2">{{ line.root_group }}</td><td class="px-2 py-2">{{ line.schedule_label }}</td><td class="px-2 py-2 text-right">{{ '{:,.0f}'.format(line.closing_debit) }}</td><td class="px-2 py-2 text-right">{{ '{:,.0f}'.format(line.closing_credit) }}</td><td class="px-2 py-2"><form method="post" action="/tools/accounting/financial-statements/mapping" class="flex min-w-[300px] items-center gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="financial_year" value="{{ financial_year }}"><input type="hidden" name="ledger_name" value="{{ line.ledger_name }}"><select name="schedule_code" class="w-52 rounded-lg border-slate-300 text-xs">{% for code,label,statement in mapping_choices %}<option value="{{ code }}" {% if code == line.schedule_code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><button class="rounded-lg border border-slate-300 bg-white px-2 py-1 font-semibold">Save</button></form></td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -25,6 +25,7 @@
|
||||
<a href="/tools/accounting/master-integrity{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Master Integrity Engine</a>
|
||||
<a href="/tools/accounting/stock-items{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Stock Item Intelligence</a>
|
||||
<a href="/tools/accounting/opening-balances{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Opening Balance Review</a>
|
||||
<a href="/tools/accounting/financial-statements{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800 hover:bg-indigo-100">Trial Balance & Financial Statements</a>
|
||||
<a href="/tools/accounting/voucher-engine{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="block rounded-lg px-3 py-2 text-sm hover:bg-white">Accounting Voucher Engine</a>
|
||||
</div></details>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.26.20"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.26.21"
|
||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
__version__ = "1.26.20"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
__version__ = "1.26.21"
|
||||
|
||||
@@ -4566,6 +4566,11 @@ class AgentCommandProcessor:
|
||||
GROUP BY lm.ledger_guid,lm.ledger_name,lm.parent_group,lm.opening_balance,lm.closing_balance
|
||||
ORDER BY lm.parent_group,lm.ledger_name LIMIT ?""",(limit,)).fetchall()]
|
||||
return {"rows":rows,"count":len(rows)}
|
||||
if query == "group_master":
|
||||
rows=[dict(r) for r in db.execute(
|
||||
"""SELECT master_guid,name,parent,reserved_name,is_revenue,is_deemed_positive,is_active
|
||||
FROM group_master ORDER BY lower(name),name LIMIT ?""",(limit,)).fetchall()]
|
||||
return {"rows":rows,"count":len(rows)}
|
||||
if query == "stock_items":
|
||||
rows=[dict(r) for r in db.execute("SELECT * FROM stock_item_master ORDER BY product_name LIMIT ?",(limit,)).fetchall()]
|
||||
return {"rows":rows,"count":len(rows)}
|
||||
|
||||
@@ -63,6 +63,7 @@ from app.modules.accounting.cash_payment_ui import router as accounting_cash_pay
|
||||
from app.modules.accounting.creditors_aging_ui import router as accounting_creditors_aging_ui_router
|
||||
from app.modules.accounting.gst_reconciliation_ui import router as accounting_gst_reconciliation_ui_router
|
||||
from app.modules.accounting.tds_ui import router as accounting_tds_ui_router
|
||||
from app.modules.accounting.financial_statements_ui import router as accounting_financial_statements_ui_router
|
||||
from app.modules.registrations.ui import router as registrations_ui_router
|
||||
from app.modules.credential_vault.ui import router as credential_vault_ui_router
|
||||
from app.modules.client_identity.ui import router as client_identity_ui_router
|
||||
@@ -110,6 +111,7 @@ def mount_ui(app: FastAPI) -> None:
|
||||
app.include_router(accounting_creditors_aging_ui_router)
|
||||
app.include_router(accounting_gst_reconciliation_ui_router)
|
||||
app.include_router(accounting_tds_ui_router)
|
||||
app.include_router(accounting_financial_statements_ui_router)
|
||||
app.include_router(work_tracker_ui_router)
|
||||
app.include_router(billing_ui_router)
|
||||
app.include_router(platform_billing_ui_router)
|
||||
|
||||
Reference in New Issue
Block a user