Auto generate engagement tasks after AQMM activation
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Trial Balance & Financial Statements</h1>
|
||||
<p class="mt-1 max-w-4xl text-sm text-slate-500">Generate Trial Balance, Profit & Loss Account, Balance Sheet and ledger schedules directly from the registered Client/FY Accounting Mirror. The mirror remains read-only.</p>
|
||||
<p class="mt-1 max-w-4xl text-sm text-slate-500">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.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally Accounting</a>
|
||||
{% if selected_client and report %}<a href="/tools/accounting/financial-statements/excel?client_id={{ selected_client.id }}&financial_year={{ financial_year }}" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Download Excel</a>{% endif %}
|
||||
{% if selected_client and report %}<a href="/tools/accounting/financial-statements/excel?client_id={{ selected_client.id }}&financial_year={{ financial_year }}" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Download Formula Excel</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,100 +18,72 @@
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<form method="get" class="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px_auto] md:items-end">
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">ERP Client</span>
|
||||
<select name="client_id" class="mt-2 w-full rounded-xl border-slate-300 text-sm">
|
||||
<option value="">Select client</option>
|
||||
{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and client.id == selected_client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">Financial Year</span>
|
||||
<select name="financial_year" class="mt-2 w-full rounded-xl border-slate-300 text-sm">
|
||||
{% for fy in financial_year_options %}<option value="{{ fy }}" {% if fy == financial_year %}selected{% endif %}>FY {{ fy }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block"><span class="text-xs font-semibold uppercase tracking-wide text-slate-500">ERP Client</span><select name="client_id" class="mt-2 w-full rounded-xl border-slate-300 text-sm"><option value="">Select client</option>{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and client.id == selected_client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}</select></label>
|
||||
<label class="block"><span class="text-xs font-semibold uppercase tracking-wide text-slate-500">Financial Year</span><select name="financial_year" class="mt-2 w-full rounded-xl border-slate-300 text-sm">{% for fy in financial_year_options %}<option value="{{ fy }}" {% if fy == financial_year %}selected{% endif %}>FY {{ fy }}</option>{% endfor %}</select></label>
|
||||
<button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Load from Mirror</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if selected_client and report %}
|
||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Mirror FY</div><div class="mt-2 text-lg font-semibold text-slate-900">{{ financial_year }}</div><div class="mt-1 text-xs text-slate-500">Version {{ report.current_payload.mirror_version_no or report.current_payload.version_no or '-' }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Ledgers</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ report.current.count }}</div><div class="mt-1 text-xs text-slate-500">{{ report.current.group_count }} Tally groups loaded</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Previous Year</div><div class="mt-2 text-lg font-semibold {% if previous_available %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ previous_fy }}</div><div class="mt-1 text-xs text-slate-500">{{ 'Comparative mirror loaded' if previous_available else 'No registered comparative mirror' }}</div></div>
|
||||
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Mapping Review</div><div class="mt-2 text-2xl font-semibold {% if statements.unmapped|length == 0 %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ statements.unmapped|length }}</div><div class="mt-1 text-xs text-slate-500">Unmapped ledgers</div></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Mirror FY</div><div class="mt-2 text-lg font-semibold text-slate-900">{{ financial_year }}</div><div class="mt-1 text-xs text-slate-500">Version {{ report.current_payload.mirror_version_no or report.current_payload.version_no or '-' }}</div></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Ledgers</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ report.current.count }}</div><div class="mt-1 text-xs text-slate-500">{{ report.current.group_count }} Tally groups loaded</div></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Comparative</div><div class="mt-2 text-lg font-semibold {% if previous_available %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ previous_fy }}</div><div class="mt-1 text-xs text-slate-500">{{ 'Previous-year mirror loaded' if previous_available else 'No registered comparative mirror' }}</div></div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">Mapping Review</div><div class="mt-2 text-2xl font-semibold {% if statements.unmapped|length == 0 %}text-emerald-700{% else %}text-amber-700{% endif %}">{{ statements.unmapped|length }}</div><div class="mt-1 text-xs text-slate-500">Unmapped ledgers</div></div>
|
||||
</section>
|
||||
|
||||
{% if report.current.hierarchy_warning %}
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">The mirror Trial Balance loaded, but the Local Agent could not return the full Tally group hierarchy. Update the Local Agent to the latest version for automatic custom-group resolution. Immediate parent-group classification is still being used.</div>
|
||||
{% endif %}
|
||||
{% if report.current.hierarchy_warning %}<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">The 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.</div>{% endif %}
|
||||
{% if statements.balance_sheet_difference|abs > 1 %}<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">Balance Sheet review required: Assets and Equity/Liabilities differ by ₹ {{ '{:,.2f}'.format(statements.balance_sheet_difference) }} after mapped finalisation. Review classification/abnormal balances before final use.</div>{% elif statements.profit_transfer_applied %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Current-year profit/loss was not yet reflected in source capital balances, so the ERP applied a read-only finalisation transfer. The mirror itself was not changed.</div>{% endif %}
|
||||
|
||||
{% if statements.balance_sheet_difference|abs > 1 %}
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">Balance Sheet review required: Assets and Equity/Liabilities differ by ₹ {{ '{:,.2f}'.format(statements.balance_sheet_difference) }} after mapped finalisation. Review unmapped or incorrectly classified ledgers before using the statements as final.</div>
|
||||
{% elif statements.profit_transfer_applied %}
|
||||
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Current-year profit/loss was not yet reflected in the source capital balances, so the ERP applied a read-only finalisation transfer for this financial statement view. The Accounting Mirror was not changed.</div>
|
||||
{% endif %}
|
||||
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4"><div><h2 class="text-lg font-semibold text-slate-900">Statement of Balance Sheet</h2><p class="text-xs text-slate-500">Only comparative headings with a value in at least one year are shown.</p></div><div class="text-xs font-medium text-slate-500">₹ · Comparative presentation</div></div>
|
||||
<div class="overflow-x-auto p-5"><div class="mx-auto max-w-5xl">
|
||||
<div class="mb-3 text-center"><div class="font-semibold text-slate-900">{{ selected_client.client_name }}</div><div class="text-sm font-semibold underline">Statement of Balance Sheet for the Year Ended 31st March {{ financial_year[:4]|int + 1 }}</div></div>
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead><tr class="bg-slate-50"><th class="border border-slate-500 px-3 py-2 text-left">Particulars</th><th class="w-16 border border-slate-500 px-2 py-2 text-center">Note</th><th class="w-36 border border-slate-500 px-3 py-2 text-right">31 March {{ financial_year[:4]|int + 1 }}</th><th class="w-36 border border-slate-500 px-3 py-2 text-right">31 March {{ financial_year[:4] }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" class="border-x border-slate-500 px-3 py-2 font-bold">I EQUITY AND LIABILITIES</td></tr>
|
||||
{% for code in statements.visible_bs_liability_codes %}{% set meta=schedules.get(code) %}<tr><td class="border-x border-slate-300 px-3 py-1.5">{{ meta.label }}</td><td class="border-x border-slate-300 px-2 py-1.5 text-center">{{ statements.presentation_notes.get(code,'') }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}</td></tr>{% endfor %}
|
||||
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2 text-right">Total Liabilities</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.current_liabilities) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.previous_liabilities) }}</td></tr>
|
||||
<tr><td colspan="4" class="border-x border-slate-500 px-3 py-2 font-bold">II ASSETS</td></tr>
|
||||
{% for code in statements.visible_bs_asset_codes %}{% set meta=schedules.get(code) %}<tr><td class="border-x border-slate-300 px-3 py-1.5">{{ meta.label }}</td><td class="border-x border-slate-300 px-2 py-1.5 text-center">{{ statements.presentation_notes.get(code,'') }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}</td></tr>{% endfor %}
|
||||
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2 text-right">Total Assets</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.current_assets) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.previous_assets) }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="mt-2 text-xs italic text-slate-500">The accompanying notes are an integral part of the financial statements.</p>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">Balance Sheet</h2><p class="text-xs text-slate-500">Presentation follows the ruled comparative format used in your existing statements.</p></div>
|
||||
<div class="overflow-x-auto p-5">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead><tr class="bg-slate-50"><th class="border border-slate-400 px-3 py-2 text-left">Particulars</th><th class="border border-slate-400 px-3 py-2">Note</th><th class="border border-slate-400 px-3 py-2 text-right">31 Mar {{ financial_year[:4]|int + 1 }}</th><th class="border border-slate-400 px-3 py-2 text-right">31 Mar {{ financial_year[:4] }}</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4" class="border-x border-slate-400 px-3 py-2 font-bold">EQUITY AND LIABILITIES</td></tr>
|
||||
{% for code, meta in schedules.items() if meta.statement == 'BS' and meta.bs_side == 'LIABILITY' and (statements.current_bs.get(code,0) or statements.previous_bs.get(code,0)) %}
|
||||
<tr><td class="border-x border-slate-300 px-3 py-1.5">{{ meta.label }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-center">{{ loop.index }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2">Total Liabilities</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.current_liabilities) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.previous_liabilities) }}</td></tr>
|
||||
<tr><td colspan="4" class="border-x border-slate-400 px-3 py-2 font-bold">ASSETS</td></tr>
|
||||
{% for code, meta in schedules.items() if meta.statement == 'BS' and meta.bs_side == 'ASSET' and (statements.current_bs.get(code,0) or statements.previous_bs.get(code,0)) %}
|
||||
<tr><td class="border-x border-slate-300 px-3 py-1.5">{{ meta.label }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-center">-</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.current_bs.get(code,0)) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(statements.previous_bs.get(code,0)) }}</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2">Total Assets</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.current_assets) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(statements.previous_assets) }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
|
||||
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">Profit & Loss Account</h2><p class="text-xs text-slate-500">Generated from mapped mirror ledgers; inventory movement is used for cost of goods sold.</p></div>
|
||||
<div class="overflow-x-auto p-5">
|
||||
{% set cp=statements.current_pl %}{% set pp=statements.previous_pl %}
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead><tr class="bg-slate-50"><th class="border border-slate-400 px-3 py-2 text-left">Particulars</th><th class="border border-slate-400 px-3 py-2 text-right">Current FY</th><th class="border border-slate-400 px-3 py-2 text-right">Previous FY</th></tr></thead>
|
||||
<tbody>
|
||||
{% for label,key in [('Revenue from operations','REVENUE'),('Other Income','OTHER_INCOME'),('Total Income','TOTAL_INCOME'),('Cost of goods sold','COST_OF_GOODS_SOLD'),('Direct Expenses','DIRECT_EXPENSES'),('Employee benefits expense','EMPLOYEE_BENEFITS'),('Finance Cost','FINANCE_COST'),('Depreciation and amortization expense','DEPRECIATION'),('Other expenses','OTHER_EXPENSES'),('Total expenses','TOTAL_EXPENSES'),('Profit/(Loss) for the year','PROFIT')] %}
|
||||
<tr class="{% if key in ['TOTAL_INCOME','TOTAL_EXPENSES','PROFIT'] %}font-bold{% endif %}"><td class="border border-slate-300 px-3 py-1.5">{{ label }}</td><td class="border border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(cp.get(key,0)) }}</td><td class="border border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(pp.get(key,0)) }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4"><div><h2 class="text-lg font-semibold text-slate-900">Statement of Profit & Loss</h2><p class="text-xs text-slate-500">Nil rows are hidden only when both years are nil.</p></div><div class="text-xs font-medium text-slate-500">₹ · Formula-driven in Excel</div></div>
|
||||
<div class="overflow-x-auto p-5"><div class="mx-auto max-w-5xl">
|
||||
{% set cp=statements.current_pl %}{% set pp=statements.previous_pl %}
|
||||
<div class="mb-3 text-center"><div class="font-semibold text-slate-900">{{ selected_client.client_name }}</div><div class="text-sm font-semibold underline">Statement of Profit and Loss for the Year Ended 31st March {{ financial_year[:4]|int + 1 }}</div></div>
|
||||
<table class="w-full border-collapse text-sm"><thead><tr class="bg-slate-50"><th class="border border-slate-500 px-3 py-2 text-left">Particulars</th><th class="w-16 border border-slate-500 px-2 py-2 text-center">Note</th><th class="w-36 border border-slate-500 px-3 py-2 text-right">31 March {{ financial_year[:4]|int + 1 }}</th><th class="w-36 border border-slate-500 px-3 py-2 text-right">31 March {{ financial_year[:4] }}</th></tr></thead><tbody>
|
||||
{% if cp.REVENUE|abs >= 0.005 or pp.REVENUE|abs >= 0.005 %}<tr><td class="border-x border-slate-300 px-3 py-1.5">I Revenue from operations</td><td class="border-x border-slate-300 px-2 py-1.5 text-center">{{ statements.presentation_notes.get('REVENUE','') }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(cp.REVENUE) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(pp.REVENUE) }}</td></tr>{% endif %}
|
||||
{% if cp.OTHER_INCOME|abs >= 0.005 or pp.OTHER_INCOME|abs >= 0.005 %}<tr><td class="border-x border-slate-300 px-3 py-1.5">II Other Income</td><td class="border-x border-slate-300 px-2 py-1.5 text-center">{{ statements.presentation_notes.get('OTHER_INCOME','') }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(cp.OTHER_INCOME) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(pp.OTHER_INCOME) }}</td></tr>{% endif %}
|
||||
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2">III Total Income (I+II)</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(cp.TOTAL_INCOME) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(pp.TOTAL_INCOME) }}</td></tr>
|
||||
<tr><td colspan="4" class="border-x border-slate-500 px-3 py-2 font-bold">IV Expenses</td></tr>
|
||||
{% 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 %}<tr><td class="border-x border-slate-300 px-6 py-1.5">{{ label }}</td><td class="border-x border-slate-300 px-2 py-1.5 text-center">{{ statements.presentation_notes.get(ncode,'') }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(cp.get(key,0)) }}</td><td class="border-x border-slate-300 px-3 py-1.5 text-right">{{ '{:,.0f}'.format(pp.get(key,0)) }}</td></tr>{% endif %}{% endfor %}
|
||||
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2 text-right">Total expenses</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(cp.TOTAL_EXPENSES) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(pp.TOTAL_EXPENSES) }}</td></tr>
|
||||
<tr class="font-bold"><td class="border border-slate-500 px-3 py-2">V Profit/(Loss) for the year</td><td class="border border-slate-500"></td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(cp.PROFIT) }}</td><td class="border border-slate-500 px-3 py-2 text-right">{{ '{:,.0f}'.format(pp.PROFIT) }}</td></tr>
|
||||
</tbody></table>
|
||||
<p class="mt-2 text-xs italic text-slate-500">The accompanying notes are an integral part of the financial statements.</p>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
{% if is_partnership %}
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="font-semibold text-slate-900">Partnership Profit Allocation</h2><p class="mt-1 text-sm text-slate-500">Ratios are Client/FY-specific. When they total 100%, the year's profit/loss is allocated to partner funds for the final Balance Sheet without changing the mirror.</p></div><div class="text-sm font-semibold {% if statements.profit_share_total == 100 %}text-emerald-700{% else %}text-amber-700{% endif %}">Configured ratio: {{ '%.2f'|format(statements.profit_share_total) }}%</div></div>
|
||||
<form method="post" action="/tools/accounting/financial-statements/partnership" class="mt-4 space-y-3">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="financial_year" value="{{ financial_year }}">
|
||||
<div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50"><th class="px-3 py-2 text-left">Partner</th><th class="px-3 py-2 text-right">Profit Share %</th><th class="px-3 py-2 text-left">Capital / Current Ledger (optional)</th></tr></thead><tbody>
|
||||
{% for row in partner_rows %}<tr><td class="px-3 py-2"><input name="partner_name" value="{{ row.partner_name }}" class="w-full rounded-lg border-slate-300"></td><td class="px-3 py-2"><input name="profit_share_percent" type="number" step="0.0001" value="{{ row.profit_share_percent }}" class="w-32 rounded-lg border-slate-300 text-right"></td><td class="px-3 py-2"><input name="target_ledger_name" value="{{ row.target_ledger_name }}" class="w-full rounded-lg border-slate-300" placeholder="e.g. Partner Current Account"></td></tr>{% endfor %}
|
||||
{% if not partner_rows %}{% for i in range(2) %}<tr><td class="px-3 py-2"><input name="partner_name" class="w-full rounded-lg border-slate-300"></td><td class="px-3 py-2"><input name="profit_share_percent" type="number" step="0.0001" class="w-32 rounded-lg border-slate-300 text-right"></td><td class="px-3 py-2"><input name="target_ledger_name" class="w-full rounded-lg border-slate-300"></td></tr>{% endfor %}{% endif %}
|
||||
</tbody></table></div>
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Save Ratio & Recalculate</button>
|
||||
</form>
|
||||
<form method="post" action="/tools/accounting/financial-statements/partnership" class="mt-4 space-y-3"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="financial_year" value="{{ financial_year }}"><div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-slate-50"><th class="px-3 py-2 text-left">Partner</th><th class="px-3 py-2 text-right">Profit Share %</th><th class="px-3 py-2 text-left">Capital / Current Ledger (optional)</th></tr></thead><tbody>{% for row in partner_rows %}<tr><td class="px-3 py-2"><input name="partner_name" value="{{ row.partner_name }}" class="w-full rounded-lg border-slate-300"></td><td class="px-3 py-2"><input name="profit_share_percent" type="number" step="0.0001" value="{{ row.profit_share_percent }}" class="w-32 rounded-lg border-slate-300 text-right"></td><td class="px-3 py-2"><input name="target_ledger_name" value="{{ row.target_ledger_name }}" class="w-full rounded-lg border-slate-300" placeholder="e.g. Partner Current Account"></td></tr>{% endfor %}{% if not partner_rows %}{% for i in range(2) %}<tr><td class="px-3 py-2"><input name="partner_name" class="w-full rounded-lg border-slate-300"></td><td class="px-3 py-2"><input name="profit_share_percent" type="number" step="0.0001" class="w-32 rounded-lg border-slate-300 text-right"></td><td class="px-3 py-2"><input name="target_ledger_name" class="w-full rounded-lg border-slate-300"></td></tr>{% endfor %}{% endif %}</tbody></table></div><button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Save Ratio & Recalculate</button></form>
|
||||
{% if statements.partnership_allocations %}<div class="mt-4 grid gap-2 md:grid-cols-2 xl:grid-cols-5">{% for row in statements.partnership_allocations %}<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs text-slate-500">{{ row.partner_name }} · {{ row.profit_share_percent }}%</div><div class="mt-1 font-semibold text-slate-900">₹ {{ '{:,.0f}'.format(row.amount) }}</div></div>{% endfor %}</div>{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="rounded-2xl bg-white p-5 shadow-soft">
|
||||
<div class="flex items-center justify-between gap-3"><div><h2 class="font-semibold text-slate-900">Trial Balance & Ledger Mapping</h2><p class="mt-1 text-sm text-slate-500">Default classification follows the complete Tally group hierarchy. Use an override only when a ledger needs a different financial-statement schedule.</p></div></div>
|
||||
<div class="mt-4 overflow-x-auto"><table class="min-w-full text-xs"><thead><tr class="bg-slate-50"><th class="px-2 py-2 text-left">Ledger</th><th class="px-2 py-2 text-left">Tally Group</th><th class="px-2 py-2 text-left">Root Group</th><th class="px-2 py-2 text-left">Schedule</th><th class="px-2 py-2 text-right">Closing Dr</th><th class="px-2 py-2 text-right">Closing Cr</th><th class="px-2 py-2">Mapping</th></tr></thead><tbody class="divide-y divide-slate-100">
|
||||
{% for line in current_lines %}<tr class="{% if line.schedule_code == 'UNMAPPED' %}bg-amber-50{% endif %}"><td class="px-2 py-2 font-medium">{{ line.ledger_name }}</td><td class="px-2 py-2">{{ line.parent_group }}</td><td class="px-2 py-2">{{ line.root_group }}</td><td class="px-2 py-2">{{ line.schedule_label }}</td><td class="px-2 py-2 text-right">{{ '{:,.0f}'.format(line.closing_debit) }}</td><td class="px-2 py-2 text-right">{{ '{:,.0f}'.format(line.closing_credit) }}</td><td class="px-2 py-2"><form method="post" action="/tools/accounting/financial-statements/mapping" class="flex min-w-[300px] items-center gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="financial_year" value="{{ financial_year }}"><input type="hidden" name="ledger_name" value="{{ line.ledger_name }}"><select name="schedule_code" class="w-52 rounded-lg border-slate-300 text-xs">{% for code,label,statement in mapping_choices %}<option value="{{ code }}" {% if code == line.schedule_code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><button class="rounded-lg border border-slate-300 bg-white px-2 py-1 font-semibold">Save</button></form></td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
<details class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<summary class="cursor-pointer list-none px-5 py-4"><div class="flex items-center justify-between"><div><h2 class="font-semibold text-slate-900">Trial Balance & Ledger Mapping</h2><p class="mt-1 text-sm text-slate-500">Review mode retains the complete ledger master, including zero-balance ledgers, so mappings are not lost between years.</p></div><span class="rounded-lg bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Open Review</span></div></summary>
|
||||
<div class="border-t border-slate-200 p-5"><div class="overflow-x-auto"><table class="min-w-full text-xs"><thead><tr class="bg-slate-50"><th class="px-2 py-2 text-left">Ledger</th><th class="px-2 py-2 text-left">Tally Group</th><th class="px-2 py-2 text-left">Root Group</th><th class="px-2 py-2 text-left">Schedule</th><th class="px-2 py-2 text-right">Closing Dr</th><th class="px-2 py-2 text-right">Closing Cr</th><th class="px-2 py-2">Mapping</th></tr></thead><tbody class="divide-y divide-slate-100">{% for line in current_lines %}<tr class="{% if line.schedule_code == 'UNMAPPED' %}bg-amber-50{% endif %}"><td class="px-2 py-2 font-medium">{{ line.ledger_name }}</td><td class="px-2 py-2">{{ line.parent_group }}</td><td class="px-2 py-2">{{ line.root_group }}</td><td class="px-2 py-2">{{ line.schedule_label }}</td><td class="px-2 py-2 text-right">{{ '{:,.0f}'.format(line.closing_debit) }}</td><td class="px-2 py-2 text-right">{{ '{:,.0f}'.format(line.closing_credit) }}</td><td class="px-2 py-2"><form method="post" action="/tools/accounting/financial-statements/mapping" class="flex min-w-[300px] items-center gap-2"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="financial_year" value="{{ financial_year }}"><input type="hidden" name="ledger_name" value="{{ line.ledger_name }}"><select name="schedule_code" class="w-52 rounded-lg border-slate-300 text-xs">{% for code,label,statement in mapping_choices %}<option value="{{ code }}" {% if code == line.schedule_code %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><button class="rounded-lg border border-slate-300 bg-white px-2 py-1 font-semibold">Save</button></form></td></tr>{% endfor %}</tbody></table></div></div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -149,10 +149,10 @@
|
||||
{% if closure_checklist.closure_block_reason %}<div class="mt-3 rounded-xl border border-amber-200 bg-white px-4 py-3 text-sm text-amber-800">Closure pending: {{ closure_checklist.closure_block_reason }}</div>{% endif %}
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-3 lg:grid-cols-6">
|
||||
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">Tasks</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.normal_tasks_completed else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.normal_tasks_completed else 'Pending' }}</div></div>
|
||||
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">Tasks</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.normal_tasks_completed else 'text-amber-700' }}">{{ 'Not Generated' if not closure_summary.tasks_generated else ('Completed' if closure_checklist.normal_tasks_completed else 'Pending') }}</div></div>
|
||||
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">AQMM Acceptance</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.aqmm_acceptance_completed else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.aqmm_acceptance_completed else 'Pending' }}</div></div>
|
||||
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">AQMM Tasks</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.aqmm_tasks_completed else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.aqmm_tasks_completed else 'Pending' }}</div></div>
|
||||
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">Evidence / Reviews</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.evidence_review_completed else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.evidence_review_completed else 'Pending' }}</div></div>
|
||||
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">AQMM Tasks</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.aqmm_tasks_completed else 'text-amber-700' }}">{{ 'Not Generated' if not closure_summary.tasks_generated else ('Completed' if closure_checklist.aqmm_tasks_completed else 'Pending') }}</div></div>
|
||||
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">Evidence / Reviews</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.evidence_review_completed else 'text-amber-700' }}">{{ 'Not Generated' if not closure_summary.tasks_generated else ('Completed' if closure_checklist.evidence_review_completed else 'Pending') }}</div></div>
|
||||
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">Final Release</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.final_documents_released else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.final_documents_released else 'Pending' }}</div></div>
|
||||
<div class="rounded-xl bg-white px-4 py-3 border border-emerald-100"><div class="text-xs font-semibold uppercase text-slate-500">UDIN</div><div class="mt-1 text-sm font-medium {{ 'text-emerald-700' if closure_checklist.udin_completed else 'text-amber-700' }}">{{ 'Completed' if closure_checklist.udin_completed else 'Pending' }}</div></div>
|
||||
</div>
|
||||
@@ -201,7 +201,7 @@
|
||||
<td class="px-4 py-3 text-right"><a href="/documents/tasks/{{ task.id }}" class="text-sm font-medium text-brand-700 hover:underline">Open Task Documents</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">No execution tasks generated yet. Generate work tracker tasks first.</td></tr>
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">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.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user