Files
arrr-erp/app/modules/accounting/financial_statements_service.py
T
2026-09-22 16:22:08 +05:30

1055 lines
52 KiB
Python

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"]
# TallyPrime predefined subgroup hierarchy. Some mirror extractions expose the
# subgroup name on the ledger but do not carry parent rows for predefined
# groups in group_master. Keep this local fallback so standard Tally groups
# never become "Unmapped" merely because the hierarchy master is incomplete.
DEFAULT_TALLY_GROUP_PARENTS: dict[str, str] = {
"bank accounts": "Current Assets",
"cash-in-hand": "Current Assets",
"cash in hand": "Current Assets",
"deposits (asset)": "Current Assets",
"deposits asset": "Current Assets",
"loans and advances (asset)": "Current Assets",
"loans & advances (asset)": "Current Assets",
"stock-in-hand": "Current Assets",
"stock in hand": "Current Assets",
"sundry debtors": "Current Assets",
"sundry debtor": "Current Assets",
"duties and taxes": "Current Liabilities",
"duties & taxes": "Current Liabilities",
"provisions": "Current Liabilities",
"sundry creditors": "Current Liabilities",
"sundry creditor": "Current Liabilities",
"bank od a/c": "Loans (Liability)",
"bank od account": "Loans (Liability)",
"secured loans": "Loans (Liability)",
"unsecured loans": "Loans (Liability)",
"reserves & surplus": "Capital Account",
"reserves and surplus": "Capital Account",
}
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
ZERO_TOLERANCE = 0.005
def _nonzero(value: Any) -> bool:
return abs(_amount(value)) >= ZERO_TOLERANCE
def _either_nonzero(current: Any, previous: Any) -> bool:
return _nonzero(current) or _nonzero(previous)
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:
key = _norm(current)
seen.add(key)
last = current
parent = parents.get(key, "") or DEFAULT_TALLY_GROUP_PARENTS.get(key, "")
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, closing_balance: float = 0.0) -> str:
ledger = _norm(ledger_name)
parent = _norm(parent_group)
root = _norm(root_group)
combined = f"{ledger} {parent} {root}"
# First classify the exact Tally default subgroup carried on the ledger.
# This works even when group_master does not contain predefined group rows.
if parent in {"bank accounts", "bank account"}:
# Tally balance convention: positive = credit, negative = debit. A bank
# account with a credit balance is presented as an overdraft/borrowing.
return "SHORT_TERM_BORROWINGS" if _amount(closing_balance) > 0 else "CASH_BANK"
if parent in {"cash-in-hand", "cash in hand"}:
return "CASH_BANK"
if parent in {"deposits (asset)", "deposits asset", "loans and advances (asset)", "loans & advances (asset)"}:
return "LOANS_ADVANCES"
if parent in {"stock-in-hand", "stock in hand"}:
return "INVENTORIES"
if parent in {"sundry debtors", "sundry debtor"}:
return "TRADE_RECEIVABLES"
if parent in {"sundry creditors", "sundry creditor"}:
return "TRADE_PAYABLES"
if parent in {"bank od a/c", "bank od account"}:
return "SHORT_TERM_BORROWINGS"
if parent in {"secured loans", "unsecured loans"}:
return "NONCURRENT_BORROWINGS"
if parent in {"reserves & surplus", "reserves and surplus"}:
return "RESERVES"
if parent == "provisions":
return "PROVISIONS"
# Common custom groups used by firms. These are still deterministic group
# mappings; users can override any exceptional ledger from the UI.
if parent in {"current account", "partners current account", "partner current account"}:
return "PARTNER_FUNDS"
if parent in {"creditor for expenses", "expense creditors", "expenses payable"}:
return "OTHER_CURRENT_LIABILITIES"
# Duties & Taxes can legitimately hold either asset or liability balances.
# Prefer the ledger's purpose, then use Tally's balance sign as a fallback.
if parent in {"duties and taxes", "duties & taxes"}:
if _keyword_any(ledger, [
"input", "receivable", "advance tax", "tcs - pur", "tcs pur",
"tds receivable", "tcs receivable", "gst input", "itc"
]):
return "OTHER_CURRENT_ASSETS"
if _keyword_any(ledger, [
"payable", "output", "tds on", "tcs payable", "rcm payable",
"pf payable", "esi payable"
]):
return "STATUTORY_LIABILITIES"
return "OTHER_CURRENT_ASSETS" if _amount(closing_balance) < 0 else "STATUTORY_LIABILITIES"
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"
# Tally's special Profit & Loss A/c is a balance-sheet carried-forward item
# when it appears as a Primary ledger; do not leave it unmapped.
if parent == "primary" and _keyword_any(ledger, ["profit & loss", "profit and loss", "p&l"]):
return "RESERVES"
# Last deterministic name fallbacks for accounts whose custom group master
# is not available. These do not override recognised Tally groups.
if _keyword_any(ledger, ["capital account"]):
return "PARTNER_FUNDS"
if _keyword_any(ledger, ["current account"]) and not _keyword_any(ledger, ["bank current account"]):
return "PARTNER_FUNDS"
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 reporting_amount(self) -> float:
"""Financial-statement amount with the natural schedule sign.
Tally balance convention in the mirror is positive=credit and
negative=debit. Assets/expenses therefore use -balance while
liabilities/income use +balance. Abnormal balances remain negative and
print in parentheses rather than being silently converted to absolute.
"""
meta = SCHEDULES.get(self.schedule_code, SCHEDULES["UNMAPPED"])
if meta.get("statement") == "BS":
return round(self.closing_balance if meta.get("bs_side") == "LIABILITY" else -self.closing_balance, 2)
if meta.get("statement") == "PL":
return round(self.closing_balance if meta.get("pl_side") == "INCOME" else -self.closing_balance, 2)
return round(self.closing_balance, 2)
@property
def opening_reporting_amount(self) -> float:
meta = SCHEDULES.get(self.schedule_code, SCHEDULES["UNMAPPED"])
if meta.get("statement") == "BS":
return round(self.opening_balance if meta.get("bs_side") == "LIABILITY" else -self.opening_balance, 2)
if meta.get("statement") == "PL":
return round(self.opening_balance if meta.get("pl_side") == "INCOME" else -self.opening_balance, 2)
return round(self.opening_balance, 2)
def _balance_split(value: float) -> tuple[float, float]:
# Tally mirror convention: negative balances are debit, positive are credit.
value = _amount(value)
return (abs(value) if value < 0 else 0.0, 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()
}
for child, parent_name in DEFAULT_TALLY_GROUP_PARENTS.items():
parents.setdefault(child, parent_name)
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, _amount(row.get("closing_balance")))
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:
if line.schedule_code != "UNMAPPED":
totals[line.schedule_code] += line.reporting_amount
return {k: round(v, 2) for k, v in totals.items()}
def _inventory_values(lines: list[LedgerLine]) -> tuple[float, float]:
opening = sum(x.opening_reporting_amount for x in lines if x.schedule_code == "INVENTORIES")
closing = sum(x.reporting_amount 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)
# Presentation metadata: suppress rows/schedules only when BOTH comparative
# years are nil. This keeps the review engine complete while the final
# statements remain concise and print-ready. Note numbering is shared by
# the web view and Excel export so references remain stable.
presentation_notes: dict[str, int] = {}
next_note = 1
visible_bs_liability_codes: list[str] = []
visible_bs_asset_codes: list[str] = []
for code, meta in sorted(SCHEDULES.items(), key=lambda kv: int(kv[1].get("order", 999))):
if meta.get("statement") != "BS":
continue
cur = current_bs.get(code, 0.0)
prev = previous_bs.get(code, 0.0)
if not _either_nonzero(cur, prev):
continue
presentation_notes[code] = next_note
next_note += 1
if meta.get("bs_side") == "LIABILITY":
visible_bs_liability_codes.append(code)
elif meta.get("bs_side") == "ASSET":
visible_bs_asset_codes.append(code)
visible_pl_note_codes: list[str] = []
for code in ["REVENUE", "OTHER_INCOME"]:
if _either_nonzero(current_pl.get(code, 0.0), previous_pl.get(code, 0.0)):
presentation_notes[code] = next_note
visible_pl_note_codes.append(code)
next_note += 1
if _either_nonzero(current_pl.get("COST_OF_GOODS_SOLD", 0.0), previous_pl.get("COST_OF_GOODS_SOLD", 0.0)):
presentation_notes["COST_OF_GOODS_SOLD"] = next_note
next_note += 1
for code in ["DIRECT_EXPENSES", "EMPLOYEE_BENEFITS", "FINANCE_COST", "DEPRECIATION", "OTHER_EXPENSES"]:
if _either_nonzero(current_pl.get(code, 0.0), previous_pl.get(code, 0.0)):
presentation_notes[code] = next_note
visible_pl_note_codes.append(code)
next_note += 1
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"],
"presentation_notes": presentation_notes,
"visible_bs_liability_codes": visible_bs_liability_codes,
"visible_bs_asset_codes": visible_bs_asset_codes,
"visible_pl_note_codes": visible_pl_note_codes,
}
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 _excel_formula(cell, formula: str) -> None:
cell.value = formula
cell.number_format = '#,##0;[Red](#,##0);-'
cell.alignment = Alignment(horizontal="right")
def _excel_quote_sheet(name: str) -> str:
return "'" + str(name).replace("'", "''") + "'"
def _union_schedule_rows(code: str, current_lines: list[LedgerLine], previous_lines: list[LedgerLine]) -> list[tuple[str, float, float]]:
cur = {_norm(x.ledger_name): x for x in current_lines if x.schedule_code == code}
prev = {_norm(x.ledger_name): x for x in previous_lines if x.schedule_code == code}
display_names: dict[str, str] = {}
for x in current_lines:
if x.schedule_code == code:
display_names.setdefault(_norm(x.ledger_name), x.ledger_name)
for x in previous_lines:
if x.schedule_code == code:
display_names.setdefault(_norm(x.ledger_name), x.ledger_name)
rows: list[tuple[str, float, float]] = []
for key in sorted(set(cur) | set(prev), key=lambda k: display_names.get(k, k).casefold()):
current = cur.get(key).reporting_amount if key in cur else 0.0
previous = prev.get(key).reporting_amount if key in prev else 0.0
# Presentation rule requested by the firm: omit a ledger only when BOTH
# comparative-year balances are nil. Abnormal balances are retained.
if _either_nonzero(current, previous):
rows.append((display_names.get(key, key), current, previous))
return rows
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)
try:
wb.calculation.calcMode = "auto"
wb.calculation.fullCalcOnLoad = True
wb.calculation.forceFullCalc = True
except Exception:
pass
start_year = int(financial_year.split('-')[0])
current_end = f"31st March {start_year + 1}"
previous_end = f"31st March {start_year}"
note_map = dict(statements.get("presentation_notes") or {})
# ----------------------- Trial Balance (review sheet) -----------------------
ws_tb = wb.create_sheet("Trial Balance")
_set_page(ws_tb, landscape=True, repeat_rows="1:5")
_title(ws_tb, client_name, address, "Trial Balance", current_end, 8)
_header_row(ws_tb, 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.casefold(), x.parent_group.casefold(), x.ledger_name.casefold())):
ws_tb.cell(r, 1, line.ledger_name)
ws_tb.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_tb.cell(r, col), val)
r += 1
total_row = r
ws_tb.cell(total_row, 1, "Total").font = Font(bold=True)
for col in range(3, 9):
letter = get_column_letter(col)
_excel_formula(ws_tb.cell(total_row, col), f"=SUM({letter}6:{letter}{total_row - 1})")
ws_tb.cell(total_row, col).font = Font(bold=True)
ws_tb.cell(total_row, col).border = Border(top=DOUBLE, bottom=DOUBLE)
_apply_table_border(ws_tb, 5, total_row, 1, 8)
for i, w in enumerate([34, 24, 14, 14, 14, 14, 14, 14], 1):
ws_tb.column_dimensions[get_column_letter(i)].width = w
ws_tb.print_area = f"A1:H{total_row}"
# ---------------- Consolidated Notes & Schedules (primary schedule pack) ----------------
ws_notes = wb.create_sheet("Notes & Schedules")
_set_page(ws_notes)
ws_notes.freeze_panes = "A5"
ws_notes.sheet_view.showGridLines = False
ws_notes.merge_cells("A1:C1")
ws_notes["A1"] = client_name
ws_notes["A1"].font = Font(bold=True, size=11)
ws_notes.merge_cells("A2:C2")
ws_notes["A2"] = address
ws_notes["A2"].font = Font(size=9)
ws_notes.merge_cells("A3:C3")
ws_notes["A3"] = f"Notes Forming Part of the Financial Statements for the Year Ended {current_end}"
ws_notes["A3"].font = Font(bold=True, underline="single", size=10)
ws_notes.column_dimensions['A'].width = 58
ws_notes.column_dimensions['B'].width = 18
ws_notes.column_dimensions['C'].width = 18
note_anchor: dict[str, int] = {}
note_total_refs: dict[str, tuple[str, str]] = {}
partner_formula_cells: list[tuple[str, float]] = []
r_notes = 5
# Only numbered notes that are actually visible in the final statements.
visible_note_codes = sorted(note_map, key=lambda c: note_map[c])
for code in visible_note_codes:
if code == "COST_OF_GOODS_SOLD":
continue
if code not in SCHEDULES:
continue
meta = SCHEDULES[code]
note_anchor[code] = r_notes
ws_notes.merge_cells(start_row=r_notes, start_column=1, end_row=r_notes, end_column=3)
tc = ws_notes.cell(r_notes, 1, f"Note - {note_map[code]}: {meta['label']}")
tc.font = Font(bold=True, size=9)
tc.fill = PatternFill("solid", fgColor="EDEDED")
tc.border = Border(top=MEDIUM, bottom=MEDIUM, left=MEDIUM, right=MEDIUM)
r_notes += 1
_header_row(ws_notes, r_notes, ["Particulars", current_end, previous_end])
block_start = r_notes
detail_start = r_notes + 1
r_notes += 1
rows = _union_schedule_rows(code, current_lines, previous_lines)
for label, current_value, previous_value in rows:
ws_notes.cell(r_notes, 1, label)
_money(ws_notes.cell(r_notes, 2), current_value)
_money(ws_notes.cell(r_notes, 3), previous_value)
r_notes += 1
# Read-only finalisation entries are formulas, not hard-coded values.
adjustment_start = r_notes
if code == "PARTNER_FUNDS" and statements.get("profit_transfer_applied"):
allocations = statements.get("partnership_allocations") or []
if allocations:
for alloc in allocations:
ws_notes.cell(r_notes, 1, f"Add: Share of Profit / (Loss) - {alloc.get('partner_name')}")
ratio = float(alloc.get("profit_share_percent") or 0) / 100.0
partner_formula_cells.append((f"B{r_notes}", ratio))
_excel_formula(ws_notes.cell(r_notes, 2), "=0") # patched after P&L row is known
_money(ws_notes.cell(r_notes, 3), 0)
r_notes += 1
else:
ws_notes.cell(r_notes, 1, "Add: Current Year Profit / (Loss)")
partner_formula_cells.append((f"B{r_notes}", 1.0))
_excel_formula(ws_notes.cell(r_notes, 2), "=0")
_money(ws_notes.cell(r_notes, 3), 0)
r_notes += 1
total = r_notes
ws_notes.cell(total, 1, "Total").font = Font(bold=True)
if total - 1 >= detail_start:
_excel_formula(ws_notes.cell(total, 2), f"=SUM(B{detail_start}:B{total - 1})")
_excel_formula(ws_notes.cell(total, 3), f"=SUM(C{detail_start}:C{total - 1})")
else:
_excel_formula(ws_notes.cell(total, 2), "=0")
_excel_formula(ws_notes.cell(total, 3), "=0")
for cc in range(1, 4):
ws_notes.cell(total, cc).border = Border(top=DOUBLE, bottom=DOUBLE)
_apply_table_border(ws_notes, block_start, total, 1, 3)
note_total_refs[code] = (f"B{total}", f"C{total}")
r_notes = total + 3
# Purchase detail annexure is formula-linked into Cost of Goods Sold.
purchase_anchor = r_notes
ws_notes.merge_cells(start_row=r_notes, start_column=1, end_row=r_notes, end_column=3)
pc = ws_notes.cell(r_notes, 1, "Annexure - Purchases")
pc.font = Font(bold=True, size=9)
pc.fill = PatternFill("solid", fgColor="F6F6F6")
pc.border = Border(top=MEDIUM, bottom=MEDIUM, left=MEDIUM, right=MEDIUM)
r_notes += 1
_header_row(ws_notes, r_notes, ["Particulars", current_end, previous_end])
purchase_block_start = r_notes
purchase_detail_start = r_notes + 1
r_notes += 1
for label, current_value, previous_value in _union_schedule_rows("PURCHASES", current_lines, previous_lines):
ws_notes.cell(r_notes, 1, label)
_money(ws_notes.cell(r_notes, 2), current_value)
_money(ws_notes.cell(r_notes, 3), previous_value)
r_notes += 1
purchase_total_row = r_notes
ws_notes.cell(r_notes, 1, "Total Purchases").font = Font(bold=True)
if purchase_total_row - 1 >= purchase_detail_start:
_excel_formula(ws_notes.cell(r_notes, 2), f"=SUM(B{purchase_detail_start}:B{purchase_total_row - 1})")
_excel_formula(ws_notes.cell(r_notes, 3), f"=SUM(C{purchase_detail_start}:C{purchase_total_row - 1})")
else:
_excel_formula(ws_notes.cell(r_notes, 2), "=0")
_excel_formula(ws_notes.cell(r_notes, 3), "=0")
for cc in range(1, 4): ws_notes.cell(r_notes, cc).border = Border(top=DOUBLE, bottom=DOUBLE)
_apply_table_border(ws_notes, purchase_block_start, purchase_total_row, 1, 3)
r_notes += 3
# Numbered Cost of Goods Sold note, matching the final P&L presentation.
if "COST_OF_GOODS_SOLD" in note_map:
code = "COST_OF_GOODS_SOLD"
note_anchor[code] = r_notes
ws_notes.merge_cells(start_row=r_notes, start_column=1, end_row=r_notes, end_column=3)
cc = ws_notes.cell(r_notes, 1, f"Note - {note_map[code]}: Cost of Goods Sold")
cc.font = Font(bold=True, size=9)
cc.fill = PatternFill("solid", fgColor="EDEDED")
cc.border = Border(top=MEDIUM, bottom=MEDIUM, left=MEDIUM, right=MEDIUM)
r_notes += 1
_header_row(ws_notes, r_notes, ["Particulars", current_end, previous_end])
cogs_block_start = r_notes
r_notes += 1
inventory_ref = note_total_refs.get("INVENTORIES")
ws_notes.cell(r_notes, 1, "Inventory at the beginning of the year")
# Current opening inventory equals previous-year closing inventory when comparative note exists.
if inventory_ref:
_excel_formula(ws_notes.cell(r_notes, 2), f"={inventory_ref[1]}")
else:
_money(ws_notes.cell(r_notes, 2), statements["current_pl"].get("OPENING_STOCK", 0))
_money(ws_notes.cell(r_notes, 3), statements["previous_pl"].get("OPENING_STOCK", 0))
r_notes += 1
ws_notes.cell(r_notes, 1, "Add: Purchases during the year")
_excel_formula(ws_notes.cell(r_notes, 2), f"=B{purchase_total_row}")
_excel_formula(ws_notes.cell(r_notes, 3), f"=C{purchase_total_row}")
r_notes += 1
ws_notes.cell(r_notes, 1, "Less: Inventory at the end of the year")
if inventory_ref:
_excel_formula(ws_notes.cell(r_notes, 2), f"={inventory_ref[0]}")
_excel_formula(ws_notes.cell(r_notes, 3), f"={inventory_ref[1]}")
else:
_money(ws_notes.cell(r_notes, 2), statements["current_pl"].get("CLOSING_STOCK", 0))
_money(ws_notes.cell(r_notes, 3), statements["previous_pl"].get("CLOSING_STOCK", 0))
r_notes += 1
cogs_total_row = r_notes
ws_notes.cell(r_notes, 1, "Cost of goods sold").font = Font(bold=True)
_excel_formula(ws_notes.cell(r_notes, 2), f"=B{r_notes-3}+B{r_notes-2}-B{r_notes-1}")
_excel_formula(ws_notes.cell(r_notes, 3), f"=C{r_notes-3}+C{r_notes-2}-C{r_notes-1}")
for cidx in range(1, 4): ws_notes.cell(r_notes, cidx).border = Border(top=DOUBLE, bottom=DOUBLE)
_apply_table_border(ws_notes, cogs_block_start, cogs_total_row, 1, 3)
note_total_refs[code] = (f"B{cogs_total_row}", f"C{cogs_total_row}")
r_notes += 3
# Optional partnership annexure remains visible and formula-driven.
allocation_cells: list[tuple[str, float]] = []
if partnership_shares:
ws_notes.merge_cells(start_row=r_notes, start_column=1, end_row=r_notes, end_column=3)
ac = ws_notes.cell(r_notes, 1, "Annexure - Partnership Profit Allocation")
ac.font = Font(bold=True, size=9)
ac.fill = PatternFill("solid", fgColor="F6F6F6")
ac.border = Border(top=MEDIUM, bottom=MEDIUM, left=MEDIUM, right=MEDIUM)
r_notes += 1
_header_row(ws_notes, r_notes, ["Partner / Target Ledger", "Profit Share %", "Share of Profit / (Loss)"])
alloc_block_start = r_notes
r_notes += 1
for share in partnership_shares:
label = str(share.get('partner_name') or '')
if share.get('target_ledger_name'):
label += f" - {share.get('target_ledger_name')}"
ws_notes.cell(r_notes, 1, label)
ratio = float(share.get('profit_share_percent') or 0) / 100.0
ws_notes.cell(r_notes, 2, ratio)
ws_notes.cell(r_notes, 2).number_format = '0.00%'
allocation_cells.append((f"C{r_notes}", ratio))
_excel_formula(ws_notes.cell(r_notes, 3), "=0") # patched after P&L
r_notes += 1
alloc_total_row = r_notes
ws_notes.cell(r_notes, 1, "Total").font = Font(bold=True)
_excel_formula(ws_notes.cell(r_notes, 2), f"=SUM(B{alloc_block_start+1}:B{r_notes-1})")
ws_notes.cell(r_notes, 2).number_format = '0.00%'
_excel_formula(ws_notes.cell(r_notes, 3), f"=SUM(C{alloc_block_start+1}:C{r_notes-1})")
for cidx in range(1, 4): ws_notes.cell(r_notes, cidx).border = Border(top=DOUBLE, bottom=DOUBLE)
_apply_table_border(ws_notes, alloc_block_start, alloc_total_row, 1, 3)
r_notes += 2
ws_notes.print_area = f"A1:C{max(1, r_notes)}"
# ----------------------------- Balance Sheet -----------------------------
ws_bs = wb.create_sheet("Balance Sheet")
_set_page(ws_bs, repeat_rows="1:5")
_title(ws_bs, client_name, address, "Statement of Balance Sheet", current_end, 4)
_header_row(ws_bs, 5, ["Particulars", "Note", current_end, previous_end])
r = 6
ws_bs.cell(r, 1, "I EQUITY AND LIABILITIES").font = Font(bold=True)
for cidx in range(1,5): ws_bs.cell(r,cidx).border = Border(top=MEDIUM,bottom=THIN)
r += 1
liability_start = r
for code in statements.get("visible_bs_liability_codes") or []:
meta = SCHEDULES[code]
ws_bs.cell(r, 1, meta["label"])
ws_bs.cell(r, 2, note_map.get(code, ""))
if code in note_anchor:
ws_bs.cell(r, 2).hyperlink = f"#'Notes & Schedules'!A{note_anchor[code]}"
ref = note_total_refs.get(code)
if ref:
_excel_formula(ws_bs.cell(r, 3), f"='Notes & Schedules'!{ref[0]}")
_excel_formula(ws_bs.cell(r, 4), f"='Notes & Schedules'!{ref[1]}")
else:
_money(ws_bs.cell(r, 3), statements["current_bs"].get(code,0))
_money(ws_bs.cell(r, 4), statements.get("previous_bs",{}).get(code,0))
r += 1
total_liability_row = r
ws_bs.cell(r,1,"Total Liabilities").font = Font(bold=True)
if r-1 >= liability_start:
_excel_formula(ws_bs.cell(r,3), f"=SUM(C{liability_start}:C{r-1})")
_excel_formula(ws_bs.cell(r,4), f"=SUM(D{liability_start}:D{r-1})")
else:
_excel_formula(ws_bs.cell(r,3), "=0"); _excel_formula(ws_bs.cell(r,4), "=0")
for cidx in range(1,5): ws_bs.cell(r,cidx).border=Border(top=DOUBLE,bottom=DOUBLE)
r += 2
ws_bs.cell(r,1,"II ASSETS").font=Font(bold=True)
for cidx in range(1,5): ws_bs.cell(r,cidx).border=Border(top=MEDIUM,bottom=THIN)
r += 1
asset_start = r
for code in statements.get("visible_bs_asset_codes") or []:
meta=SCHEDULES[code]
ws_bs.cell(r,1,meta["label"])
ws_bs.cell(r,2,note_map.get(code,""))
if code in note_anchor: ws_bs.cell(r,2).hyperlink=f"#'Notes & Schedules'!A{note_anchor[code]}"
ref=note_total_refs.get(code)
if ref:
_excel_formula(ws_bs.cell(r,3),f"='Notes & Schedules'!{ref[0]}")
_excel_formula(ws_bs.cell(r,4),f"='Notes & Schedules'!{ref[1]}")
else:
_money(ws_bs.cell(r,3),statements["current_bs"].get(code,0)); _money(ws_bs.cell(r,4),statements.get("previous_bs",{}).get(code,0))
r+=1
total_asset_row=r
ws_bs.cell(r,1,"Total Assets").font=Font(bold=True)
if r-1>=asset_start:
_excel_formula(ws_bs.cell(r,3),f"=SUM(C{asset_start}:C{r-1})"); _excel_formula(ws_bs.cell(r,4),f"=SUM(D{asset_start}:D{r-1})")
else:
_excel_formula(ws_bs.cell(r,3),"=0"); _excel_formula(ws_bs.cell(r,4),"=0")
for cidx in range(1,5): ws_bs.cell(r,cidx).border=Border(top=DOUBLE,bottom=DOUBLE)
r += 2
ws_bs.merge_cells(start_row=r,start_column=1,end_row=r,end_column=4)
ws_bs.cell(r,1,"The accompanying notes are an integral part of the financial statements").font=Font(italic=True,size=8)
r += 3
ws_bs.cell(r,1,f"For {client_name}").font=Font(bold=True,size=9)
ws_bs.cell(r,4,"For the Auditors").font=Font(bold=True,size=9)
r += 3
ws_bs.cell(r,1,"Partner / Proprietor / Authorised Signatory").font=Font(size=8)
ws_bs.cell(r,4,"Chartered Accountant").font=Font(size=8)
_apply_table_border(ws_bs,5,total_asset_row,1,4)
for col,w in {1:48,2:9,3:18,4:18}.items(): ws_bs.column_dimensions[get_column_letter(col)].width=w
ws_bs.print_area=f"A1:D{r}"
# ---------------------------- Profit & Loss ----------------------------
ws_pl=wb.create_sheet("Profit and Loss")
_set_page(ws_pl, repeat_rows="1:5")
_title(ws_pl,client_name,address,"Statement of Profit and Loss",current_end,4)
_header_row(ws_pl,5,["Particulars","Note",current_end,previous_end])
r=6
pl_rows: list[tuple[str,str,str|None,str|None]] = []
if _either_nonzero(statements["current_pl"].get("REVENUE",0),statements["previous_pl"].get("REVENUE",0)):
pl_rows.append(("I","Revenue from operations","REVENUE","REVENUE"))
if _either_nonzero(statements["current_pl"].get("OTHER_INCOME",0),statements["previous_pl"].get("OTHER_INCOME",0)):
pl_rows.append(("II","Other Income","OTHER_INCOME","OTHER_INCOME"))
pl_rows.append(("III","Total Income (I+II)",None,"TOTAL_INCOME"))
expense_source=[
("(a)","Cost of goods sold","COST_OF_GOODS_SOLD","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"),
]
visible_expenses=[x for x in expense_source if _either_nonzero(statements["current_pl"].get(x[3],0),statements["previous_pl"].get(x[3],0))]
if visible_expenses:
pl_rows.append(("IV","Expenses:",None,None))
pl_rows.extend(visible_expenses)
pl_rows.append(("","Total expenses",None,"TOTAL_EXPENSES"))
pl_rows.append(("V","Profit/(Loss) for the year",None,"PROFIT"))
income_value_rows=[]; expense_value_rows=[]; profit_row=None
for roman,label,note_code,key in pl_rows:
ws_pl.cell(r,1,f"{roman} {label}".strip())
if label in {"Total Income (I+II)","Expenses:","Total expenses","Profit/(Loss) for the year"}: ws_pl.cell(r,1).font=Font(bold=True)
if note_code and note_code in note_map:
ws_pl.cell(r,2,note_map[note_code])
if note_code in note_anchor: ws_pl.cell(r,2).hyperlink=f"#'Notes & Schedules'!A{note_anchor[note_code]}"
ref=note_total_refs.get(note_code)
if ref:
_excel_formula(ws_pl.cell(r,3),f"='Notes & Schedules'!{ref[0]}")
_excel_formula(ws_pl.cell(r,4),f"='Notes & Schedules'!{ref[1]}")
elif key == "TOTAL_INCOME":
current_cells = [f"C{rr}" for rr in income_value_rows]
previous_cells = [f"D{rr}" for rr in income_value_rows]
_excel_formula(ws_pl.cell(r,3),"=" + "+".join(current_cells) if current_cells else "=0")
_excel_formula(ws_pl.cell(r,4),"=" + "+".join(previous_cells) if previous_cells else "=0")
elif key == "TOTAL_EXPENSES":
_excel_formula(ws_pl.cell(r,3),"=" + "+".join(f"C{rr}" for rr in expense_value_rows) if expense_value_rows else "=0")
_excel_formula(ws_pl.cell(r,4),"=" + "+".join(f"D{rr}" for rr in expense_value_rows) if expense_value_rows else "=0")
elif key == "PROFIT":
total_income_row = next(rr for rr in range(6,r) if "Total Income" in str(ws_pl.cell(rr,1).value))
total_exp_row = next((rr for rr in range(6,r) if "Total expenses" in str(ws_pl.cell(rr,1).value)), None)
_excel_formula(ws_pl.cell(r,3),f"=C{total_income_row}" + (f"-C{total_exp_row}" if total_exp_row else ""))
_excel_formula(ws_pl.cell(r,4),f"=D{total_income_row}" + (f"-D{total_exp_row}" if total_exp_row else ""))
profit_row=r
if key in {"REVENUE","OTHER_INCOME"}: income_value_rows.append(r)
if key in {"COST_OF_GOODS_SOLD","DIRECT_EXPENSES","EMPLOYEE_BENEFITS","FINANCE_COST","DEPRECIATION","OTHER_EXPENSES"}: expense_value_rows.append(r)
if label in {"Total Income (I+II)","Total expenses","Profit/(Loss) for the year"}:
for cidx in range(1,5): ws_pl.cell(r,cidx).border=Border(top=THIN,bottom=DOUBLE)
r += 1
r += 1
ws_pl.merge_cells(start_row=r,start_column=1,end_row=r,end_column=4)
ws_pl.cell(r,1,"The accompanying notes are an integral part of the financial statements").font=Font(italic=True,size=8)
r += 3
ws_pl.cell(r,1,f"For {client_name}").font=Font(bold=True,size=9)
ws_pl.cell(r,4,"For the Auditors").font=Font(bold=True,size=9)
_apply_table_border(ws_pl,5,r-4,1,4)
for col,w in {1:56,2:9,3:18,4:18}.items(): ws_pl.column_dimensions[get_column_letter(col)].width=w
ws_pl.print_area=f"A1:D{r}"
# Patch partnership formulas now that the formula-driven P&L profit row exists.
if profit_row:
quoted_pl=_excel_quote_sheet("Profit and Loss")
for cell_ref,ratio in partner_formula_cells:
_excel_formula(ws_notes[cell_ref],f"={quoted_pl}!C{profit_row}*{ratio:.10f}")
for cell_ref,ratio in allocation_cells:
_excel_formula(ws_notes[cell_ref],f"={quoted_pl}!C{profit_row}*{ratio:.10f}")
# -------------------- Hidden compatibility note sheets --------------------
# Existing consumers that expect Nxx sheets continue to work. Each hidden
# compatibility sheet links to the consolidated schedule rather than copying
# values, so formulas are preserved and there is only one source of totals.
for code in visible_note_codes:
if code not in note_anchor:
continue
label = "Cost of Goods Sold" if code == "COST_OF_GOODS_SOLD" else SCHEDULES[code]["label"]
sh = wb.create_sheet(_schedule_sheet_name(note_map[code], label))
_set_page(sh)
_title(sh,client_name,address,f"Note - {note_map[code]}: {label}",current_end,3)
_header_row(sh,5,["Particulars",current_end,previous_end])
sh.cell(6,1,"See consolidated Notes & Schedules")
sh.cell(6,1).hyperlink=f"#'Notes & Schedules'!A{note_anchor[code]}"
ref=note_total_refs.get(code)
if ref:
_excel_formula(sh.cell(6,2),f"='Notes & Schedules'!{ref[0]}")
_excel_formula(sh.cell(6,3),f"='Notes & Schedules'!{ref[1]}")
_apply_table_border(sh,5,6,1,3)
sh.sheet_state="hidden"
# Unmapped review remains available and unsuppressed.
if statements.get("unmapped"):
ws_um=wb.create_sheet("Unmapped Ledgers")
_set_page(ws_um,landscape=True)
_title(ws_um,client_name,address,"Unmapped Ledgers - Review Required",current_end,5)
_header_row(ws_um,5,["Ledger","Immediate Tally Group","Resolved Root Group","Closing Debit","Closing Credit"])
r=6
for line in statements["unmapped"]:
ws_um.cell(r,1,line.ledger_name); ws_um.cell(r,2,line.parent_group); ws_um.cell(r,3,line.root_group)
_money(ws_um.cell(r,4),line.closing_debit); _money(ws_um.cell(r,5),line.closing_credit); r+=1
_apply_table_border(ws_um,5,r-1,1,5)
for col,w in {1:35,2:28,3:28,4:18,5:18}.items(): ws_um.column_dimensions[get_column_letter(col)].width=w
# Professional print order: statements first, then one continuous schedule
# pack, followed by review/audit workings. No formulas are converted to values.
preferred=["Balance Sheet","Profit and Loss","Notes & Schedules","Trial Balance"]
ordered=[]
for name in preferred:
if name in wb.sheetnames: ordered.append(wb[name])
ordered += [sh for sh in wb.worksheets if sh not in ordered]
wb._sheets = ordered
wb.active = 0
bio=BytesIO()
wb.save(bio)
return bio.getvalue()