Add transaction balance reconciliation and reorder analyzer workbook
This commit is contained in:
@@ -619,6 +619,56 @@ def _safe_sheet_name(value: str) -> str:
|
|||||||
return re.sub(r"[][\\:*?/]", "_", value)[:31]
|
return re.sub(r"[][\\:*?/]", "_", value)[:31]
|
||||||
|
|
||||||
|
|
||||||
|
def _statement_rows_in_chronology(tx: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Return transaction rows in statement chronology for reconciliation.
|
||||||
|
|
||||||
|
The transaction-classification sheet preserves parser order. The balance
|
||||||
|
reconciliation sheet must instead follow each statement from opening to
|
||||||
|
closing, including for bank exports that list newest transactions first.
|
||||||
|
"""
|
||||||
|
if tx.empty:
|
||||||
|
return tx.copy()
|
||||||
|
|
||||||
|
frames: list[pd.DataFrame] = []
|
||||||
|
statement_values = tx.get("statement_id", pd.Series("STMT-001", index=tx.index)).fillna("STMT-001")
|
||||||
|
for statement_id in pd.unique(statement_values):
|
||||||
|
part = tx.loc[statement_values.eq(statement_id)].copy()
|
||||||
|
dates = pd.to_datetime(part.get("transaction_date"), errors="coerce")
|
||||||
|
valid_dates = dates.dropna()
|
||||||
|
if len(valid_dates) >= 2 and valid_dates.iloc[0] > valid_dates.iloc[-1]:
|
||||||
|
part = part.iloc[::-1].copy()
|
||||||
|
part["statement_row_no"] = range(1, len(part) + 1)
|
||||||
|
frames.append(part)
|
||||||
|
return pd.concat(frames, ignore_index=True) if frames else tx.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def _statement_opening_balances(metas) -> dict[str, float]:
|
||||||
|
"""Map statement IDs to their printed or commonly-derived opening balance."""
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
for position, meta in enumerate(metas, start=1):
|
||||||
|
statement_id = str(getattr(meta, "statement_id", "") or f"STMT-{position:03d}")
|
||||||
|
opening = getattr(meta, "opening_balance", None)
|
||||||
|
result[statement_id] = float(opening or 0.0)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _reorder_workbook_sheets(workbook, preferred_order: list[str]) -> None:
|
||||||
|
"""Place the primary working papers first without recreating any sheet.
|
||||||
|
|
||||||
|
XlsxWriter serialises worksheets in ``worksheets_objs`` order. Reordering
|
||||||
|
that list before workbook close preserves every worksheet object, formula,
|
||||||
|
table, hidden state and formatting while changing only the visible tab order.
|
||||||
|
"""
|
||||||
|
rank = {name: position for position, name in enumerate(preferred_order)}
|
||||||
|
original = {worksheet.get_name(): position for position, worksheet in enumerate(workbook.worksheets_objs)}
|
||||||
|
workbook.worksheets_objs.sort(
|
||||||
|
key=lambda worksheet: (
|
||||||
|
rank.get(worksheet.get_name(), len(preferred_order)),
|
||||||
|
original.get(worksheet.get_name(), len(original)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_summary_reconciliation(
|
def _write_summary_reconciliation(
|
||||||
worksheet,
|
worksheet,
|
||||||
start_row: int,
|
start_row: int,
|
||||||
@@ -751,6 +801,101 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b
|
|||||||
|
|
||||||
# Raw and Python-analysis sheets.
|
# Raw and Python-analysis sheets.
|
||||||
recon.to_excel(writer, sheet_name="Statement Reconciliation", index=False)
|
recon.to_excel(writer, sheet_name="Statement Reconciliation", index=False)
|
||||||
|
|
||||||
|
# Formula-driven transaction-level reconciliation for every bank/layout.
|
||||||
|
# It retains the original transaction row number and source PDF page so
|
||||||
|
# a reviewer can return directly to the line that first breaks continuity.
|
||||||
|
balance_rows = _statement_rows_in_chronology(tx)
|
||||||
|
opening_by_statement = _statement_opening_balances(metas)
|
||||||
|
balance_columns = [
|
||||||
|
"Statement ID", "Statement Row No.", "Transaction Row No.",
|
||||||
|
"Source File", "Page No.", "Transaction Date", "Value Date",
|
||||||
|
"Narration", "Debit", "Credit", "Statement Balance",
|
||||||
|
"Calculated Balance", "Difference", "Status", "Review Note",
|
||||||
|
]
|
||||||
|
balance_export = pd.DataFrame(columns=balance_columns)
|
||||||
|
if not balance_rows.empty:
|
||||||
|
balance_export = pd.DataFrame({
|
||||||
|
"Statement ID": balance_rows.get("statement_id", ""),
|
||||||
|
"Statement Row No.": balance_rows.get("statement_row_no", ""),
|
||||||
|
"Transaction Row No.": balance_rows.get("row_id", ""),
|
||||||
|
"Source File": balance_rows.get("source_file", ""),
|
||||||
|
"Page No.": balance_rows.get("source_page", ""),
|
||||||
|
"Transaction Date": balance_rows.get("transaction_date", pd.NaT),
|
||||||
|
"Value Date": balance_rows.get("value_date", pd.NaT),
|
||||||
|
"Narration": balance_rows.get("narration", ""),
|
||||||
|
"Debit": pd.to_numeric(balance_rows.get("debit"), errors="coerce"),
|
||||||
|
"Credit": pd.to_numeric(balance_rows.get("credit"), errors="coerce"),
|
||||||
|
"Statement Balance": pd.to_numeric(balance_rows.get("balance"), errors="coerce"),
|
||||||
|
"Calculated Balance": "",
|
||||||
|
"Difference": "",
|
||||||
|
"Status": "",
|
||||||
|
"Review Note": "",
|
||||||
|
})
|
||||||
|
balance_export.to_excel(writer, sheet_name="Balance Reconciliation", index=False)
|
||||||
|
ws_balance = writer.sheets["Balance Reconciliation"]
|
||||||
|
balance_col = {name: index for index, name in enumerate(balance_columns)}
|
||||||
|
previous_statement = None
|
||||||
|
previous_calc_excel_row = None
|
||||||
|
for row_index in range(len(balance_export)):
|
||||||
|
excel_row = row_index + 2
|
||||||
|
statement_id = str(balance_export.iloc[row_index]["Statement ID"] or "STMT-001")
|
||||||
|
debit_cell = f'{_excel_col(balance_col["Debit"])}{excel_row}'
|
||||||
|
credit_cell = f'{_excel_col(balance_col["Credit"])}{excel_row}'
|
||||||
|
statement_balance_cell = f'{_excel_col(balance_col["Statement Balance"])}{excel_row}'
|
||||||
|
calculated_col_letter = _excel_col(balance_col["Calculated Balance"])
|
||||||
|
calculated_cell = f'{calculated_col_letter}{excel_row}'
|
||||||
|
difference_cell = f'{_excel_col(balance_col["Difference"])}{excel_row}'
|
||||||
|
|
||||||
|
if statement_id != previous_statement:
|
||||||
|
opening_balance = float(opening_by_statement.get(statement_id, 0.0))
|
||||||
|
calculated_formula = f'={opening_balance}+IFERROR({credit_cell},0)-IFERROR({debit_cell},0)'
|
||||||
|
else:
|
||||||
|
calculated_formula = f'={calculated_col_letter}{previous_calc_excel_row}+IFERROR({credit_cell},0)-IFERROR({debit_cell},0)'
|
||||||
|
|
||||||
|
statement_balance = balance_export.iloc[row_index]["Statement Balance"]
|
||||||
|
debit_value = balance_export.iloc[row_index]["Debit"]
|
||||||
|
credit_value = balance_export.iloc[row_index]["Credit"]
|
||||||
|
if statement_id != previous_statement:
|
||||||
|
cached_calculated = float(opening_by_statement.get(statement_id, 0.0)) + (0.0 if pd.isna(credit_value) else float(credit_value)) - (0.0 if pd.isna(debit_value) else float(debit_value))
|
||||||
|
else:
|
||||||
|
prior_cached = float(balance_export.iloc[row_index - 1].get("_cached_calculated", 0.0))
|
||||||
|
cached_calculated = prior_cached + (0.0 if pd.isna(credit_value) else float(credit_value)) - (0.0 if pd.isna(debit_value) else float(debit_value))
|
||||||
|
balance_export.loc[balance_export.index[row_index], "_cached_calculated"] = cached_calculated
|
||||||
|
cached_difference = 0.0 if pd.isna(statement_balance) else float(statement_balance) - cached_calculated
|
||||||
|
cached_status = "Reconciled" if pd.notna(statement_balance) and abs(cached_difference) <= 0.01 else "Review Required"
|
||||||
|
cached_note = "" if cached_status == "Reconciled" else ("Statement balance unavailable" if pd.isna(statement_balance) else "Running balance differs from calculated balance")
|
||||||
|
|
||||||
|
ws_balance.write_formula(row_index + 1, balance_col["Calculated Balance"], calculated_formula, money, cached_calculated)
|
||||||
|
ws_balance.write_formula(row_index + 1, balance_col["Difference"], f'=IF({statement_balance_cell}="","",{statement_balance_cell}-{calculated_cell})', money, cached_difference if pd.notna(statement_balance) else "")
|
||||||
|
ws_balance.write_formula(row_index + 1, balance_col["Status"], f'=IF({statement_balance_cell}="","Review Required",IF(ABS({difference_cell})<=0.01,"Reconciled","Review Required"))', text_fmt, cached_status)
|
||||||
|
ws_balance.write(row_index + 1, balance_col["Review Note"], cached_note, text_fmt)
|
||||||
|
|
||||||
|
previous_statement = statement_id
|
||||||
|
previous_calc_excel_row = excel_row
|
||||||
|
|
||||||
|
if len(balance_export):
|
||||||
|
ws_balance.add_table(0, 0, len(balance_export), len(balance_columns) - 1, {
|
||||||
|
"name": "tblBalanceReconciliation",
|
||||||
|
"columns": [{"header": column} for column in balance_columns],
|
||||||
|
"style": "Table Style Medium 2",
|
||||||
|
})
|
||||||
|
ws_balance.conditional_format(1, balance_col["Difference"], len(balance_export), balance_col["Difference"], {
|
||||||
|
"type": "cell", "criteria": "not between", "minimum": -0.01, "maximum": 0.01, "format": warning_fmt,
|
||||||
|
})
|
||||||
|
ws_balance.conditional_format(1, balance_col["Status"], len(balance_export), balance_col["Status"], {
|
||||||
|
"type": "text", "criteria": "containing", "value": "Review", "format": warning_fmt,
|
||||||
|
})
|
||||||
|
ws_balance.freeze_panes(1, 8)
|
||||||
|
ws_balance.set_column(balance_col["Statement ID"], balance_col["Statement ID"], 13)
|
||||||
|
ws_balance.set_column(balance_col["Statement Row No."], balance_col["Transaction Row No."], 14)
|
||||||
|
ws_balance.set_column(balance_col["Source File"], balance_col["Source File"], 28)
|
||||||
|
ws_balance.set_column(balance_col["Page No."], balance_col["Page No."], 10)
|
||||||
|
ws_balance.set_column(balance_col["Transaction Date"], balance_col["Value Date"], 13, date_format)
|
||||||
|
ws_balance.set_column(balance_col["Narration"], balance_col["Narration"], 60)
|
||||||
|
ws_balance.set_column(balance_col["Debit"], balance_col["Difference"], 18, money)
|
||||||
|
ws_balance.set_column(balance_col["Status"], balance_col["Review Note"], 24)
|
||||||
|
|
||||||
raw_export.to_excel(writer, sheet_name="All Extracted Rows", 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)
|
exact_export.to_excel(writer, sheet_name="Exact Duplicates", index=False)
|
||||||
possible_export.to_excel(writer, sheet_name="Possible Duplicates", index=False)
|
possible_export.to_excel(writer, sheet_name="Possible Duplicates", index=False)
|
||||||
@@ -964,7 +1109,8 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b
|
|||||||
worksheet.set_row(0, 22, header)
|
worksheet.set_row(0, 22, header)
|
||||||
max_col = max(0, worksheet.dim_colmax)
|
max_col = max(0, worksheet.dim_colmax)
|
||||||
if worksheet.dim_rowmax >= 0 and name not in {
|
if worksheet.dim_rowmax >= 0 and name not in {
|
||||||
"Transaction Classification", "Category Summary", "Party Summary", "Category Party Summary"
|
"Transaction Classification", "Balance Reconciliation",
|
||||||
|
"Category Summary", "Party Summary", "Category Party Summary"
|
||||||
}:
|
}:
|
||||||
worksheet.autofilter(0, 0, worksheet.dim_rowmax, max_col)
|
worksheet.autofilter(0, 0, worksheet.dim_rowmax, max_col)
|
||||||
worksheet.set_column(0, max_col, 18)
|
worksheet.set_column(0, max_col, 18)
|
||||||
@@ -974,4 +1120,29 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b
|
|||||||
writer.sheets["Masters"].set_column("A:D", 34)
|
writer.sheets["Masters"].set_column("A:D", 34)
|
||||||
# Formula/dropdown support only; not exposed as a visible report sheet.
|
# Formula/dropdown support only; not exposed as a visible report sheet.
|
||||||
writer.sheets["Masters"].hide()
|
writer.sheets["Masters"].hide()
|
||||||
|
|
||||||
|
# Keep the five principal working sheets first, then retain every other
|
||||||
|
# existing report in its established relative order.
|
||||||
|
_reorder_workbook_sheets(workbook, [
|
||||||
|
"Dashboard",
|
||||||
|
"Category Summary",
|
||||||
|
"Party Summary",
|
||||||
|
"Category Party Summary",
|
||||||
|
"Trial Balance",
|
||||||
|
"Statement Reconciliation",
|
||||||
|
"Balance Reconciliation",
|
||||||
|
"All Extracted Rows",
|
||||||
|
"Exact Duplicates",
|
||||||
|
"Possible Duplicates",
|
||||||
|
"Duplicate Summary",
|
||||||
|
"Masters",
|
||||||
|
"Transaction Classification",
|
||||||
|
"Monthly Summary",
|
||||||
|
"Transfer Comments",
|
||||||
|
"Review Items",
|
||||||
|
"Mode Summary",
|
||||||
|
"Draft Financials",
|
||||||
|
])
|
||||||
|
writer.sheets["Dashboard"].activate()
|
||||||
|
writer.sheets["Dashboard"].set_first_sheet()
|
||||||
return output
|
return output
|
||||||
|
|||||||
Reference in New Issue
Block a user