From 0e931411d6bd9676d9158b8d53ffaa0b4d6afa6d Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Mon, 20 Jul 2026 22:17:25 +0530 Subject: [PATCH] Fix multi-account bank reconciliation and output filename --- .../bank_statement_analyzer/analyzer.py | 85 +++++++++++++++++-- .../bank_statement_analyzer/service.py | 11 ++- app/modules/bank_statement_analyzer/ui.py | 2 +- 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/app/modules/bank_statement_analyzer/analyzer.py b/app/modules/bank_statement_analyzer/analyzer.py index 87a0cf3..314e01e 100644 --- a/app/modules/bank_statement_analyzer/analyzer.py +++ b/app/modules/bank_statement_analyzer/analyzer.py @@ -483,6 +483,78 @@ def reconcile(metas, all_df): return pd.DataFrame(rows) + +def _account_portfolio_summary(metas, transactions): + """Build account-level and portfolio reconciliation values. + + Running-balance delta validates each statement row. This helper handles a + separate issue: a workbook may contain multiple bank accounts or multiple + statement segments for the same account. Opening and closing balances must + therefore be selected per account, not from the first and last metadata row + across the entire upload. + """ + def _key(meta): + account = str(meta.account_number or '').strip() + return (str(meta.bank_name or '').strip(), account or str(meta.source_file or '').strip()) + + groups = {} + for position, meta in enumerate(metas): + groups.setdefault(_key(meta), []).append((position, meta)) + + rows = [] + for (bank_name, account_key), items in groups.items(): + def _sort_key(item): + position, meta = item + start = pd.to_datetime(meta.period_from, errors='coerce') + end = pd.to_datetime(meta.period_to, errors='coerce') + start_key = start if pd.notna(start) else pd.Timestamp.max + end_key = end if pd.notna(end) else pd.Timestamp.max + return (start_key, end_key, position) + + ordered = sorted(items, key=_sort_key) + first_meta = ordered[0][1] + last_meta = ordered[-1][1] + opening = next((m.opening_balance for _, m in ordered if m.opening_balance is not None), 0.0) or 0.0 + closing = next((m.closing_balance for _, m in reversed(ordered) if m.closing_balance is not None), 0.0) or 0.0 + account_number = str(first_meta.account_number or '').strip() + customer_name = next((str(m.customer_name).strip() for _, m in ordered if str(m.customer_name or '').strip()), '') + + if transactions is None or transactions.empty: + data = pd.DataFrame() + else: + mask = transactions.get('bank_name', pd.Series('', index=transactions.index)).fillna('').astype(str).eq(bank_name) + if account_number: + mask &= transactions.get('account_number', pd.Series('', index=transactions.index)).fillna('').astype(str).eq(account_number) + else: + source_files = {str(m.source_file or '') for _, m in ordered} + mask &= transactions.get('source_file', pd.Series('', index=transactions.index)).fillna('').astype(str).isin(source_files) + data = transactions.loc[mask] + + debit = float(pd.to_numeric(data.get('debit'), errors='coerce').fillna(0).sum()) if not data.empty else 0.0 + credit = float(pd.to_numeric(data.get('credit'), errors='coerce').fillna(0).sum()) if not data.empty else 0.0 + computed = round(float(opening) + credit - debit, 2) + difference = round(computed - float(closing), 2) + rows.append({ + 'bank_name': bank_name, + 'customer_name': customer_name, + 'account_number': account_number or account_key, + 'statement_segments': len(ordered), + 'period_from': next((m.period_from for _, m in ordered if m.period_from), ''), + 'period_to': next((m.period_to for _, m in reversed(ordered) if m.period_to), ''), + 'opening_balance': round(float(opening), 2), + 'total_debit': round(debit, 2), + 'total_credit': round(credit, 2), + 'computed_closing_balance': computed, + 'statement_closing_balance': round(float(closing), 2), + 'difference': difference, + 'status': 'Reconciled' if abs(difference) <= 0.01 else 'Review', + }) + + frame = pd.DataFrame(rows) + portfolio_opening = float(frame['opening_balance'].sum()) if not frame.empty else 0.0 + portfolio_closing = float(frame['statement_closing_balance'].sum()) if not frame.empty else 0.0 + return frame, round(portfolio_opening, 2), round(portfolio_closing, 2) + def monthly_summary(df): if df.empty: return pd.DataFrame() @@ -546,8 +618,7 @@ def draft_financials(df, metas): other_receipts = float(sum(categories.loc[c, "credit"] for c in categories.index if c not in receipt_categories and categories.loc[c, "credit"] > 0)) classified_payments = float(sum(categories.loc[c, "debit"] for c in payment_categories if c in categories.index)) other_payments = float(sum(categories.loc[c, "debit"] for c in categories.index if c not in payment_categories and categories.loc[c, "debit"] > 0)) - opening = next((meta.opening_balance for meta in metas if meta.opening_balance is not None), None) - closing = next((meta.closing_balance for meta in reversed(metas) if meta.closing_balance is not None), None) + _account_recon, opening, closing = _account_portfolio_summary(metas, df) return pd.DataFrame( [ ["Potential business receipts / collections", business_receipts, "Narration-based grouping of customer, cash and settlement credits", "Verify with GST/sales register and cash book"], @@ -629,11 +700,12 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b all_df = _ensure_analysis_columns(all_df) unique_df = _ensure_analysis_columns(unique_df) recon = reconcile(metas, all_df) - customer = next((meta.customer_name for meta in metas if meta.customer_name), "") - account = next((meta.account_number for meta in metas if meta.account_number), "") + account_recon, opening, statement_closing = _account_portfolio_summary(metas, unique_df) + customer_names = sorted({str(meta.customer_name).strip() for meta in metas if str(meta.customer_name or '').strip()}) + account_numbers = sorted({str(meta.account_number).strip() for meta in metas if str(meta.account_number or '').strip()}) + customer = customer_names[0] if len(customer_names) == 1 else ("Multiple Clients" if customer_names else "") + account = account_numbers[0] if len(account_numbers) == 1 else (f"Multiple Accounts ({len(account_numbers)})" if account_numbers else "") banks = ", ".join(sorted({meta.bank_name for meta in metas})) - opening = next((meta.opening_balance for meta in metas if meta.opening_balance is not None), 0.0) or 0.0 - statement_closing = next((meta.closing_balance for meta in reversed(metas) if meta.closing_balance is not None), 0.0) or 0.0 tx = unique_df[_workbook_columns(unique_df)].copy() if not unique_df.empty else pd.DataFrame(columns=_workbook_columns(unique_df)) tx.insert(0, "row_id", range(1, len(tx) + 1)) @@ -717,6 +789,7 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b # Raw and Python-analysis sheets. recon.to_excel(writer, sheet_name="Statement Reconciliation", index=False) + account_recon.to_excel(writer, sheet_name="Account Reconciliation", index=False) raw_export.to_excel(writer, sheet_name="All Extracted Rows", index=False) exact_export.to_excel(writer, sheet_name="Exact Duplicates", index=False) possible_export.to_excel(writer, sheet_name="Possible Duplicates", index=False) diff --git a/app/modules/bank_statement_analyzer/service.py b/app/modules/bank_statement_analyzer/service.py index 6a8c69c..5e93275 100644 --- a/app/modules/bank_statement_analyzer/service.py +++ b/app/modules/bank_statement_analyzer/service.py @@ -210,6 +210,15 @@ def _claim_jobs() -> list[str]: db.close() + +def _workbook_filename(metas) -> str: + """Return BankName_ClientName.xlsx using filesystem-safe segments.""" + banks = sorted({_segment(getattr(meta, "bank_name", ""), "Bank") for meta in metas if str(getattr(meta, "bank_name", "") or "").strip()}) + customers = sorted({_segment(getattr(meta, "customer_name", ""), "Client") for meta in metas if str(getattr(meta, "customer_name", "") or "").strip()}) + bank_label = banks[0] if len(banks) == 1 else ("Multi_Bank" if banks else "Bank") + client_label = customers[0] if len(customers) == 1 else ("Multiple_Clients" if customers else "Client") + return f"{bank_label}_{client_label}.xlsx" + def _process_job(job_id: str) -> None: db = CommonSessionLocal() try: @@ -229,7 +238,7 @@ def _process_job(job_id: str) -> None: ) job.progress_percent = 75 db.commit() - output = output_dir / "Bank_Statement_Analysis.xlsx" + output = output_dir / _workbook_filename(metas) export_excel( output, metas, diff --git a/app/modules/bank_statement_analyzer/ui.py b/app/modules/bank_statement_analyzer/ui.py index bbff089..e52badd 100644 --- a/app/modules/bank_statement_analyzer/ui.py +++ b/app/modules/bank_statement_analyzer/ui.py @@ -133,7 +133,7 @@ def download(job_id: str, request: Request): view = job_view(job) if not view["download_ready"]: return not_found_response(request, "Analysis workbook is not ready or has expired.") - return FileResponse(path=job.output_file, filename="Bank_Statement_Analysis.xlsx", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + return FileResponse(path=job.output_file, filename=Path(job.output_file).name, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") finally: db.close()