diff --git a/app/modules/accounting/financial_statements_service.py b/app/modules/accounting/financial_statements_service.py index 1ad9667..d7cf1b5 100644 --- a/app/modules/accounting/financial_statements_service.py +++ b/app/modules/accounting/financial_statements_service.py @@ -88,6 +88,17 @@ def _amount(value: Any) -> float: 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: @@ -463,6 +474,43 @@ def build_financial_statements( 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, @@ -480,6 +528,10 @@ def build_financial_statements( "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, } @@ -545,325 +597,458 @@ def _schedule_sheet_name(note_no: int, label: str) -> str: 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 = 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}" + try: + wb.calculation.calcMode = "auto" + wb.calculation.fullCalcOnLoad = True + wb.calculation.forceFullCalc = True + except Exception: + pass - 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)} + 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 - 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 - ws.print_area = f"A1:H{r}" + # ----------------------- 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}" - # 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 - ws.print_area = f"A1:D{r}" - - # 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 - ws.print_area = f"A1:D{r}" - - # 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.reporting_amount) - old=prev_by_name.get(_norm(line.ledger_name)); _money(ws.cell(r,3),old.reporting_amount 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 - - # Consolidated Notes & Schedules: all notes and annexures in one printable - # sheet, aligned one below another. Individual note sheets above are kept - # (hidden) for backward compatibility with existing workbook consumers. + # ---------------- Consolidated Notes & Schedules (primary schedule pack) ---------------- ws_notes = wb.create_sheet("Notes & Schedules") - _set_page(ws_notes, repeat_rows=None) + _set_page(ws_notes) ws_notes.freeze_panes = "A5" - ws_notes.page_setup.orientation = "portrait" - ws_notes.page_setup.fitToWidth = 1 - ws_notes.page_setup.fitToHeight = 0 - ws_notes.sheet_properties.pageSetUpPr.fitToPage = True ws_notes.sheet_view.showGridLines = False - ws_notes.page_margins.left = 0.28 - ws_notes.page_margins.right = 0.28 - ws_notes.page_margins.top = 0.35 - ws_notes.page_margins.bottom = 0.35 - 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.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 - consolidated_anchor: dict[str, int] = {} + note_anchor: dict[str, int] = {} + note_total_refs: dict[str, tuple[str, str]] = {} + partner_formula_cells: list[tuple[str, float]] = [] r_notes = 5 - 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] - consolidated_anchor[code] = r_notes - ws_notes.merge_cells(start_row=r_notes,start_column=1,end_row=r_notes,end_column=3) - title_cell = ws_notes.cell(r_notes,1,f"Note - {note}: {meta['label']}") - title_cell.font = Font(bold=True,size=9) - title_cell.fill = PatternFill("solid", fgColor="EDEDED") - title_cell.border = Border(top=MEDIUM,bottom=MEDIUM,left=MEDIUM,right=MEDIUM) + + # 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]) + _header_row(ws_notes, r_notes, ["Particulars", current_end, previous_end]) block_start = r_notes + detail_start = r_notes + 1 r_notes += 1 - cur=[x for x in current_lines if x.schedule_code==code] - for line in sorted(cur,key=lambda x:x.ledger_name.lower()): - ws_notes.cell(r_notes,1,line.ledger_name) - _money(ws_notes.cell(r_notes,2),line.reporting_amount) - old=prev_by_name.get(_norm(line.ledger_name)) - _money(ws_notes.cell(r_notes,3),old.reporting_amount if old and old.schedule_code==code else 0) + + 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 - if code=="PARTNER_FUNDS" and statements.get("profit_transfer_applied"): - if statements.get("partnership_allocations"): - for alloc in statements["partnership_allocations"]: - ws_notes.cell(r_notes,1,f"Add: Share of Profit / (Loss) - {alloc.get('partner_name')}") - _money(ws_notes.cell(r_notes,2),alloc.get("amount") or 0); _money(ws_notes.cell(r_notes,3),0); 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)") - _money(ws_notes.cell(r_notes,2),statements.get("current_pl",{}).get("PROFIT",0)); _money(ws_notes.cell(r_notes,3),0); r_notes += 1 - ws_notes.cell(r_notes,1,"Total").font = Font(bold=True) - _money(ws_notes.cell(r_notes,2),statements["current_bs"].get(code,statements["current_totals"].get(code,0))) - _money(ws_notes.cell(r_notes,3),statements.get("previous_bs",statements["previous_totals"]).get(code,0)) - for c in range(1,4): ws_notes.cell(r_notes,c).border=Border(top=DOUBLE,bottom=DOUBLE) - _apply_table_border(ws_notes,block_start,r_notes,1,3) + 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 - # Cost of Goods Sold annexure follows the numbered notes. - cogs_anchor = r_notes - ws_notes.merge_cells(start_row=r_notes,start_column=1,end_row=r_notes,end_column=3) - c=ws_notes.cell(r_notes,1,"Annexure - Cost of Goods Sold") - c.font=Font(bold=True,size=9); c.fill=PatternFill("solid",fgColor="EDEDED") - c.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; r_notes += 1 - for label,key in [("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")]: - ws_notes.cell(r_notes,1,label); _money(ws_notes.cell(r_notes,2),statements["current_pl"].get(key,0)); _money(ws_notes.cell(r_notes,3),statements["previous_pl"].get(key,0)) - if key=="COST_OF_GOODS_SOLD": - ws_notes.cell(r_notes,1).font=Font(bold=True) - for cc in range(1,4): ws_notes.cell(r_notes,cc).border=Border(top=DOUBLE,bottom=DOUBLE) - r_notes += 1 - _apply_table_border(ws_notes,block_start,r_notes-1,1,3) - r_notes += 2 - + # 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) - c=ws_notes.cell(r_notes,1,"Annexure - Partnership Profit Allocation") - c.font=Font(bold=True,size=9); c.fill=PatternFill("solid",fgColor="EDEDED") - c.border=Border(top=MEDIUM,bottom=MEDIUM,left=MEDIUM,right=MEDIUM) + 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 - _header_row(ws_notes,r_notes,["Partner / Target Ledger","Profit Share %","Share of Profit / (Loss)"]); block_start=r_notes; r_notes += 1 - alloc_by_name={_norm(x.get('partner_name')):x for x in statements.get('partnership_allocations') or []} for share in partnership_shares: - label=f"{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) - ws_notes.cell(r_notes,2,float(share.get('profit_share_percent') or 0)/100.0); ws_notes.cell(r_notes,2).number_format='0.00%' - alloc=alloc_by_name.get(_norm(share.get('partner_name'))); _money(ws_notes.cell(r_notes,3),(alloc or {}).get('amount') or 0); r_notes += 1 - ws_notes.cell(r_notes,1,"Total").font=Font(bold=True) - ws_notes.cell(r_notes,2,sum(float(x.get('profit_share_percent') or 0) for x in partnership_shares)/100.0); ws_notes.cell(r_notes,2).number_format='0.00%' - _money(ws_notes.cell(r_notes,3),sum(float((alloc_by_name.get(_norm(x.get('partner_name'))) or {}).get('amount') or 0) for x in partnership_shares)) - for cc in range(1,4): ws_notes.cell(r_notes,cc).border=Border(top=DOUBLE,bottom=DOUBLE) - _apply_table_border(ws_notes,block_start,r_notes,1,3) + 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)}" + ws_notes.print_area = f"A1:C{max(1, r_notes)}" - # Point Balance Sheet and P&L note links to the consolidated schedule sheet. - for sheet_name in ("Balance Sheet","Profit and Loss"): - ws_link = wb[sheet_name] - for rr in range(6, ws_link.max_row + 1): - note_val = ws_link.cell(rr,2).value - try: - note_no = int(note_val) - except (TypeError,ValueError): - continue - code = next((k for k,v in note_map.items() if int(v)==note_no),None) - if code and code in consolidated_anchor: - ws_link.cell(rr,2).hyperlink = f"#'Notes & Schedules'!A{consolidated_anchor[code]}" - # Cost-of-goods-sold line links to its annexure without disturbing note numbering. - ws_pl = wb["Profit and Loss"] - for rr in range(6, ws_pl.max_row + 1): - if "cost of goods sold" in _norm(ws_pl.cell(rr,1).value): - ws_pl.cell(rr,2).value = "Sch." - ws_pl.cell(rr,2).hyperlink = f"#'Notes & Schedules'!A{cogs_anchor}" - break + # ----------------------------- 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}" - # Keep the existing individual note/annexure sheets for compatibility, but - # hide them from the normal workbook view now that the consolidated sheet is - # the printable schedule pack. - for sh in wb.worksheets: - if sh.title.startswith("N") and len(sh.title) > 3 and sh.title[1:3].isdigit(): - sh.sheet_state = "hidden" - if sh.title in {"Cost of Goods Sold", "Partner Profit Allocation"}: - sh.sheet_state = "hidden" - # Place consolidated schedules immediately after the two statements. - wb._sheets.remove(ws_notes) - wb._sheets.insert(3, ws_notes) + # ---------------------------- 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=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"]) + 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.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 + 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() - bio=BytesIO(); wb.save(bio); return bio.getvalue() diff --git a/app/modules/accounting/templates/accounting/financial_statements.html b/app/modules/accounting/templates/accounting/financial_statements.html index 5490c67..2fe05fc 100644 --- a/app/modules/accounting/templates/accounting/financial_statements.html +++ b/app/modules/accounting/templates/accounting/financial_statements.html @@ -5,11 +5,11 @@

Tools · Accounting

Trial Balance & Financial Statements

-

Generate Trial Balance, Profit & Loss Account, Balance Sheet and ledger schedules directly from the registered Client/FY Accounting Mirror. The mirror remains read-only.

+

Presentation view from the registered Client/FY Accounting Mirror. Zero-value rows are suppressed only when both current and comparative years are nil; the underlying Trial Balance remains complete and read-only.

Back to Tally Accounting - {% if selected_client and report %}Download Excel{% endif %} + {% if selected_client and report %}Download Formula Excel{% endif %}
@@ -18,100 +18,72 @@
- - + +
{% if selected_client and report %}
-
Mirror FY
{{ financial_year }}
Version {{ report.current_payload.mirror_version_no or report.current_payload.version_no or '-' }}
-
Ledgers
{{ report.current.count }}
{{ report.current.group_count }} Tally groups loaded
-
Previous Year
{{ previous_fy }}
{{ 'Comparative mirror loaded' if previous_available else 'No registered comparative mirror' }}
-
Mapping Review
{{ statements.unmapped|length }}
Unmapped ledgers
+
Mirror FY
{{ financial_year }}
Version {{ report.current_payload.mirror_version_no or report.current_payload.version_no or '-' }}
+
Ledgers
{{ report.current.count }}
{{ report.current.group_count }} Tally groups loaded
+
Comparative
{{ previous_fy }}
{{ 'Previous-year mirror loaded' if previous_available else 'No registered comparative mirror' }}
+
Mapping Review
{{ statements.unmapped|length }}
Unmapped ledgers
- {% if report.current.hierarchy_warning %} -
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.
- {% endif %} + {% if report.current.hierarchy_warning %}
The full Tally group hierarchy was not returned by the Local Agent. The standard Tally fallback hierarchy and ledger rules are active; custom-group hierarchy will improve automatically when the latest Local Agent is available.
{% endif %} + {% if statements.balance_sheet_difference|abs > 1 %}
Balance Sheet review required: Assets and Equity/Liabilities differ by ₹ {{ '{:,.2f}'.format(statements.balance_sheet_difference) }} after mapped finalisation. Review classification/abnormal balances before final use.
{% elif statements.profit_transfer_applied %}
Current-year profit/loss was not yet reflected in source capital balances, so the ERP applied a read-only finalisation transfer. The mirror itself was not changed.
{% endif %} - {% if statements.balance_sheet_difference|abs > 1 %} -
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.
- {% elif statements.profit_transfer_applied %} -
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.
- {% endif %} +
+

Statement of Balance Sheet

Only comparative headings with a value in at least one year are shown.

₹ · Comparative presentation
+
+
{{ selected_client.client_name }}
Statement of Balance Sheet for the Year Ended 31st March {{ financial_year[:4]|int + 1 }}
+ + + + + {% for code in statements.visible_bs_liability_codes %}{% set meta=schedules.get(code) %}{% endfor %} + + + {% for code in statements.visible_bs_asset_codes %}{% set meta=schedules.get(code) %}{% endfor %} + + +
ParticularsNote31 March {{ financial_year[:4]|int + 1 }}31 March {{ financial_year[:4] }}
I  EQUITY AND LIABILITIES
{{ meta.label }}{{ statements.presentation_notes.get(code,'') }}{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}
Total Liabilities{{ '{:,.0f}'.format(statements.current_liabilities) }}{{ '{:,.0f}'.format(statements.previous_liabilities) }}
II  ASSETS
{{ meta.label }}{{ statements.presentation_notes.get(code,'') }}{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}
Total Assets{{ '{:,.0f}'.format(statements.current_assets) }}{{ '{:,.0f}'.format(statements.previous_assets) }}
+

The accompanying notes are an integral part of the financial statements.

+
+
-
-
-

Balance Sheet

Presentation follows the ruled comparative format used in your existing statements.

-
- - - - - {% 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)) %} - - {% endfor %} - - - {% 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)) %} - - {% endfor %} - - -
ParticularsNote31 Mar {{ financial_year[:4]|int + 1 }}31 Mar {{ financial_year[:4] }}
EQUITY AND LIABILITIES
{{ meta.label }}{{ loop.index }}{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}
Total Liabilities{{ '{:,.0f}'.format(statements.current_liabilities) }}{{ '{:,.0f}'.format(statements.previous_liabilities) }}
ASSETS
{{ meta.label }}-{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}
Total Assets{{ '{:,.0f}'.format(statements.current_assets) }}{{ '{:,.0f}'.format(statements.previous_assets) }}
-
-
- -
-

Profit & Loss Account

Generated from mapped mirror ledgers; inventory movement is used for cost of goods sold.

-
- {% set cp=statements.current_pl %}{% set pp=statements.previous_pl %} - - - - {% 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')] %} - - {% endfor %} - -
ParticularsCurrent FYPrevious FY
{{ label }}{{ '{:,.0f}'.format(cp.get(key,0)) }}{{ '{:,.0f}'.format(pp.get(key,0)) }}
-
-
-
+
+

Statement of Profit & Loss

Nil rows are hidden only when both years are nil.

₹ · Formula-driven in Excel
+
+ {% set cp=statements.current_pl %}{% set pp=statements.previous_pl %} +
{{ selected_client.client_name }}
Statement of Profit and Loss for the Year Ended 31st March {{ financial_year[:4]|int + 1 }}
+ + {% if cp.REVENUE|abs >= 0.005 or pp.REVENUE|abs >= 0.005 %}{% endif %} + {% if cp.OTHER_INCOME|abs >= 0.005 or pp.OTHER_INCOME|abs >= 0.005 %}{% endif %} + + + {% for label,key,ncode in [('Cost of goods sold','COST_OF_GOODS_SOLD','COST_OF_GOODS_SOLD'),('Direct Expenses','DIRECT_EXPENSES','DIRECT_EXPENSES'),('Employee benefits expense','EMPLOYEE_BENEFITS','EMPLOYEE_BENEFITS'),('Finance Cost','FINANCE_COST','FINANCE_COST'),('Depreciation and amortization expense','DEPRECIATION','DEPRECIATION'),('Other expenses','OTHER_EXPENSES','OTHER_EXPENSES')] %}{% if cp.get(key,0)|abs >= 0.005 or pp.get(key,0)|abs >= 0.005 %}{% endif %}{% endfor %} + + +
ParticularsNote31 March {{ financial_year[:4]|int + 1 }}31 March {{ financial_year[:4] }}
I  Revenue from operations{{ statements.presentation_notes.get('REVENUE','') }}{{ '{:,.0f}'.format(cp.REVENUE) }}{{ '{:,.0f}'.format(pp.REVENUE) }}
II  Other Income{{ statements.presentation_notes.get('OTHER_INCOME','') }}{{ '{:,.0f}'.format(cp.OTHER_INCOME) }}{{ '{:,.0f}'.format(pp.OTHER_INCOME) }}
III  Total Income (I+II){{ '{:,.0f}'.format(cp.TOTAL_INCOME) }}{{ '{:,.0f}'.format(pp.TOTAL_INCOME) }}
IV  Expenses
{{ label }}{{ statements.presentation_notes.get(ncode,'') }}{{ '{:,.0f}'.format(cp.get(key,0)) }}{{ '{:,.0f}'.format(pp.get(key,0)) }}
Total expenses{{ '{:,.0f}'.format(cp.TOTAL_EXPENSES) }}{{ '{:,.0f}'.format(pp.TOTAL_EXPENSES) }}
V  Profit/(Loss) for the year{{ '{:,.0f}'.format(cp.PROFIT) }}{{ '{:,.0f}'.format(pp.PROFIT) }}
+

The accompanying notes are an integral part of the financial statements.

+
+
{% if is_partnership %} -
+

Partnership Profit Allocation

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.

Configured ratio: {{ '%.2f'|format(statements.profit_share_total) }}%
-
- -
- {% for row in partner_rows %}{% endfor %} - {% if not partner_rows %}{% for i in range(2) %}{% endfor %}{% endif %} -
PartnerProfit Share %Capital / Current Ledger (optional)
- -
+
{% for row in partner_rows %}{% endfor %}{% if not partner_rows %}{% for i in range(2) %}{% endfor %}{% endif %}
PartnerProfit Share %Capital / Current Ledger (optional)
{% if statements.partnership_allocations %}
{% for row in statements.partnership_allocations %}
{{ row.partner_name }} · {{ row.profit_share_percent }}%
₹ {{ '{:,.0f}'.format(row.amount) }}
{% endfor %}
{% endif %}
{% endif %} -
-

Trial Balance & Ledger Mapping

Default classification follows the complete Tally group hierarchy. Use an override only when a ledger needs a different financial-statement schedule.

-
- {% for line in current_lines %}{% endfor %} -
LedgerTally GroupRoot GroupScheduleClosing DrClosing CrMapping
{{ line.ledger_name }}{{ line.parent_group }}{{ line.root_group }}{{ line.schedule_label }}{{ '{:,.0f}'.format(line.closing_debit) }}{{ '{:,.0f}'.format(line.closing_credit) }}
-
+
+

Trial Balance & Ledger Mapping

Review mode retains the complete ledger master, including zero-balance ledgers, so mappings are not lost between years.

Open Review
+
{% for line in current_lines %}{% endfor %}
LedgerTally GroupRoot GroupScheduleClosing DrClosing CrMapping
{{ line.ledger_name }}{{ line.parent_group }}{{ line.root_group }}{{ line.schedule_label }}{{ '{:,.0f}'.format(line.closing_debit) }}{{ '{:,.0f}'.format(line.closing_credit) }}
+
{% endif %} {% endblock %} diff --git a/app/modules/services/engagements_ui.py b/app/modules/services/engagements_ui.py index 64181d7..e29f261 100644 --- a/app/modules/services/engagements_ui.py +++ b/app/modules/services/engagements_ui.py @@ -905,6 +905,14 @@ def subscription_detail(request: Request, subscription_id: int): db.refresh(row) ensure_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id, create_declarations=False) + # Self-heal existing active engagements that became executable after AQMM approval + # but were created before automatic post-approval task generation was wired in. + # The generator is idempotent and only inserts missing template-backed tasks. + if row.is_active and row.status == "active" and not row.is_locked: + created_tasks = generate_tasks_for_subscription_if_ready(db, subscription=row, user_id=user.id) + if created_tasks: + db.commit() + db.refresh(row) db.flush() declarations = list_engagement_quality_declarations(db, subscription_id=row.id) my_pending_declarations = [d for d in declarations if d.requested_user_id == user.id and d.status == "pending"] @@ -1088,6 +1096,10 @@ def subscription_aqmm_approve(request: Request, subscription_id: int, csrf_token if row: try: approve_engagement_quality_workflow(db, subscription=row, actor_user_id=user.id) + # Approval removes the assurance quality gate. Create any missing execution + # tasks immediately so the assigned staff member sees the engagement in My Work. + # Existing task instances are preserved because the generator is idempotent. + generate_tasks_for_subscription_if_ready(db, subscription=row, user_id=user.id) db.commit() except ValueError: db.rollback() diff --git a/app/modules/services/execution.py b/app/modules/services/execution.py index b19d5fe..63714b5 100644 --- a/app/modules/services/execution.py +++ b/app/modules/services/execution.py @@ -843,10 +843,36 @@ def get_or_create_engagement_closure( return row -def _normal_tasks_completed(db: Session, *, subscription_id: int) -> bool: +def _task_generation_counts(db: Session, *, subscription: ClientServiceSubscription) -> tuple[int, int]: + """Return (expected_active_templates, generated_active_tasks) for an engagement. + + This is intentionally read-only and uses the same active firm task template scope as + generate_tasks_for_subscription(). It lets closure readiness distinguish a genuinely + completed engagement from an engagement where execution tasks were never generated. + """ + expected = db.execute( + select(func.count(FirmServiceTaskTemplate.id)).where( + FirmServiceTaskTemplate.tenant_id == subscription.tenant_id, + FirmServiceTaskTemplate.service_catalogue_id == subscription.service_catalogue_id, + FirmServiceTaskTemplate.is_active.is_(True), + ) + ).scalar_one() + generated = db.execute( + select(func.count(ClientServiceTaskInstance.id)).where( + ClientServiceTaskInstance.subscription_id == subscription.id, + ClientServiceTaskInstance.is_active.is_(True), + ) + ).scalar_one() + return int(expected or 0), int(generated or 0) + + +def _normal_tasks_completed(db: Session, *, subscription: ClientServiceSubscription) -> bool: + expected_count, generated_count = _task_generation_counts(db, subscription=subscription) + if expected_count > 0 and generated_count == 0: + return False open_task = db.execute( select(ClientServiceTaskInstance.id).where( - ClientServiceTaskInstance.subscription_id == subscription_id, + ClientServiceTaskInstance.subscription_id == subscription.id, ClientServiceTaskInstance.is_active.is_(True), ClientServiceTaskInstance.status.notin_(list(CLOSED_TASK_STATUSES)), ) @@ -891,11 +917,14 @@ def _udin_completed(db: Session, *, subscription_id: int) -> bool: def closure_readiness_for_subscription(db: Session, *, subscription: ClientServiceSubscription) -> dict: assurance = quality_required_for_engagement(subscription.engagement_type) aqmm_summary = aqmm_task_summary_for_subscription(db, subscription_id=subscription.id) - normal_tasks_done = _normal_tasks_completed(db, subscription_id=subscription.id) + expected_task_count, generated_task_count = _task_generation_counts(db, subscription=subscription) + tasks_generated = expected_task_count == 0 or generated_task_count > 0 + normal_tasks_done = _normal_tasks_completed(db, subscription=subscription) aqmm_acceptance_completed = (not assurance) or getattr(subscription, "quality_acceptance_status", None) == QUALITY_APPROVED aqmm_tasks_completed = (not assurance) or ( - aqmm_summary["mandatory_done"] == aqmm_summary["mandatory"] + tasks_generated + and aqmm_summary["mandatory_done"] == aqmm_summary["mandatory"] and aqmm_summary["evidence_missing"] == 0 and aqmm_summary["manager_review_pending"] == 0 and aqmm_summary["partner_review_pending"] == 0 @@ -903,7 +932,8 @@ def closure_readiness_for_subscription(db: Session, *, subscription: ClientServi and aqmm_summary["rework_open"] == 0 ) evidence_review_completed = (not assurance) or ( - aqmm_summary["evidence_missing"] == 0 + tasks_generated + and aqmm_summary["evidence_missing"] == 0 and aqmm_summary["manager_review_pending"] == 0 and aqmm_summary["partner_review_pending"] == 0 and aqmm_summary["review_partner_review_pending"] == 0 @@ -917,6 +947,9 @@ def closure_readiness_for_subscription(db: Session, *, subscription: ClientServi return { "assurance": assurance, "aqmm_task_summary": aqmm_summary, + "expected_task_count": expected_task_count, + "generated_task_count": generated_task_count, + "tasks_generated": tasks_generated, "normal_tasks_completed": normal_tasks_done, "aqmm_acceptance_completed": aqmm_acceptance_completed, "aqmm_tasks_completed": aqmm_tasks_completed, @@ -943,7 +976,9 @@ def update_engagement_closure_from_sources( row.normal_tasks_completed = bool(summary["normal_tasks_completed"]) blockers: list[str] = [] - if not row.normal_tasks_completed: + if not summary.get("tasks_generated", True): + blockers.append("execution tasks not generated") + elif not row.normal_tasks_completed: blockers.append("open work tracker tasks pending") if summary["assurance"]: if not row.aqmm_acceptance_completed: diff --git a/app/modules/services/templates/services/engagements/detail.html b/app/modules/services/templates/services/engagements/detail.html index bcd2c59..2a44c5f 100644 --- a/app/modules/services/templates/services/engagements/detail.html +++ b/app/modules/services/templates/services/engagements/detail.html @@ -149,10 +149,10 @@ {% if closure_checklist.closure_block_reason %}
Closure pending: {{ closure_checklist.closure_block_reason }}
{% endif %}
-
Tasks
{{ 'Completed' if closure_checklist.normal_tasks_completed else 'Pending' }}
+
Tasks
{{ 'Not Generated' if not closure_summary.tasks_generated else ('Completed' if closure_checklist.normal_tasks_completed else 'Pending') }}
AQMM Acceptance
{{ 'Completed' if closure_checklist.aqmm_acceptance_completed else 'Pending' }}
-
AQMM Tasks
{{ 'Completed' if closure_checklist.aqmm_tasks_completed else 'Pending' }}
-
Evidence / Reviews
{{ 'Completed' if closure_checklist.evidence_review_completed else 'Pending' }}
+
AQMM Tasks
{{ 'Not Generated' if not closure_summary.tasks_generated else ('Completed' if closure_checklist.aqmm_tasks_completed else 'Pending') }}
+
Evidence / Reviews
{{ 'Not Generated' if not closure_summary.tasks_generated else ('Completed' if closure_checklist.evidence_review_completed else 'Pending') }}
Final Release
{{ 'Completed' if closure_checklist.final_documents_released else 'Pending' }}
UDIN
{{ 'Completed' if closure_checklist.udin_completed else 'Pending' }}
@@ -201,7 +201,7 @@ Open Task Documents {% else %} - No execution tasks generated yet. Generate work tracker tasks first. + No execution tasks are available yet. For active engagements the system automatically creates any missing tasks from the current service task templates; reload this page after activation or AQMM approval. {% endfor %}