from __future__ import annotations import json from urllib.parse import urlencode from fastapi import APIRouter, Form, Request from fastapi.responses import RedirectResponse from app.core.db.common import CommonSessionLocal from app.core.security.csrf import get_or_create_csrf_token, validate_csrf from app.core.templating import templates from app.modules.accounting.bank_reconciliation_service import ( completed_client_jobs, list_bank_mappings, list_runs, queue_all_mapped_reconciliations, queue_reconciliation, resolve_reconciliation_item, run_items, save_bank_mapping, source_accounts, visible_bank_ledgers, visible_workstations, ) from app.modules.accounting.chart_models import AccountingChartLedger from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from sqlalchemy import select router = APIRouter( prefix="/tools/accounting/bank-reconciliation", tags=["accounting-bank-reconciliation-ui"], ) def _go(client_id=0, message="", error="", **extra): params = {} if client_id: params["client_id"] = client_id if message: params["message"] = message[:350] if error: params["error"] = error[:350] for key, value in extra.items(): if value not in ("", None): params[key] = value return RedirectResponse( "/tools/accounting/bank-reconciliation" + ("?" + urlencode(params) if params else ""), status_code=303, ) def _company_options(db, tenant_id, client_id): rows = list( db.execute( select(AccountingChartLedger.tally_guid, AccountingChartLedger.company_name) .where( AccountingChartLedger.tenant_id == int(tenant_id), AccountingChartLedger.client_id == int(client_id), ) .distinct() .order_by(AccountingChartLedger.company_name) ).all() ) return [{"guid": guid, "name": name or guid} for guid, name in rows if guid] @router.get("") def page( request: Request, client_id: int | None = None, job_id: str = "", tally_guid: str = "", run_id: int | None = None, status: str = "", message: str = "", error: str = "", ): db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.learning.view") if denied: return denied clients, scope = _visible_clients(db, request, user) selected = next( (c for c in clients if client_id and int(c.id) == int(client_id)), None, ) jobs = [] companies = [] bank_ledgers = [] workstations = [] runs = [] items = [] selected_run = None accounts = [] mappings = [] run_summaries = {} if selected: jobs = completed_client_jobs( db, tenant_id=scope.tenant_id, client_id=selected.id, limit=50, ) if job_id: accounts = source_accounts( db, tenant_id=scope.tenant_id, client_id=selected.id, job_id=job_id, ) companies = _company_options(db, scope.tenant_id, selected.id) if not tally_guid and len(companies) == 1: tally_guid = companies[0]["guid"] if tally_guid: bank_ledgers = visible_bank_ledgers( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, ) mappings = list_bank_mappings( db, tenant_id=scope.tenant_id, client_id=selected.id, tally_guid=tally_guid, ) workstations = visible_workstations( db, tenant_id=scope.tenant_id, branch_id=getattr(user, "branch_id", None), ) runs = list_runs( db, tenant_id=scope.tenant_id, client_id=selected.id, limit=30, ) if run_id: selected_run = next( (row for row in runs if int(row.id) == int(run_id)), None, ) elif runs: selected_run = runs[0] if selected_run and selected_run.status == "completed": items = run_items( db, run_id=selected_run.id, status=status, ) for row in runs: try: run_summaries[int(row.id)] = json.loads(row.summary_json or "{}") except Exception: run_summaries[int(row.id)] = {} return templates.TemplateResponse( "modules/accounting/templates/accounting/bank_reconciliation.html", { "request": request, "current_user": user, "current_user_roles": get_user_roles(db, user.id), "current_user_permissions": get_user_permissions(db, user.id), "csrf_token": get_or_create_csrf_token(request), "title": "Bank Reconciliation", "clients": clients, "selected_client": selected, "jobs": jobs, "selected_job_id": job_id, "companies": companies, "selected_tally_guid": tally_guid, "bank_ledgers": bank_ledgers, "workstations": workstations, "runs": runs, "selected_run": selected_run, "items": items, "status_filter": status, "source_accounts": accounts, "bank_mappings": mappings, "run_summaries": run_summaries, "message": message, "error": error, }, ) finally: db.close() @router.post("/run") def create_run( request: Request, client_id: int = Form(...), source_job_id: str = Form(...), tally_guid: str = Form(...), company_name: str = Form(...), bank_ledger_name: str = Form(...), account_number: str = Form(""), date_tolerance_days: int = Form(15), workstation_id: int = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.learning.manage") if denied: return denied client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") run = queue_reconciliation( db, tenant_id=scope.tenant_id, client_id=client.id, source_job_id=source_job_id, tally_guid=tally_guid, company_name=company_name, bank_ledger_name=bank_ledger_name, workstation_id=workstation_id, user_id=user.id, account_number=account_number, date_tolerance_days=date_tolerance_days, ) return _go( client.id, run_id=run.id, tally_guid=tally_guid, message=( f"Bank reconciliation run #{run.id} queued. " "The Local Agent will read the selected Tally bank ledger; no voucher is posted." ), ) except Exception as exc: db.rollback() return _go( client_id, tally_guid=tally_guid, error=str(exc), ) finally: db.close() @router.post("/mapping") def save_mapping( request: Request, client_id: int = Form(...), source_job_id: str = Form(""), tally_guid: str = Form(...), company_name: str = Form(...), account_number: str = Form(...), bank_name: str = Form(""), bank_ledger_name: str = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.learning.manage") if denied: return denied client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") save_bank_mapping( db, tenant_id=scope.tenant_id, client_id=client.id, account_number=account_number, bank_name=bank_name, tally_guid=tally_guid, company_name=company_name, bank_ledger_name=bank_ledger_name, user_id=user.id, ) return _go( client.id, job_id=source_job_id, tally_guid=tally_guid, message=f"Bank account {account_number} mapped to Tally ledger '{bank_ledger_name}'.", ) except Exception as exc: db.rollback() return _go(client_id, job_id=source_job_id, tally_guid=tally_guid, error=str(exc)) finally: db.close() @router.post("/run-all") def create_all_runs( request: Request, client_id: int = Form(...), source_job_id: str = Form(...), tally_guid: str = Form(...), company_name: str = Form(...), date_tolerance_days: int = Form(15), workstation_id: int = Form(...), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.learning.manage") if denied: return denied client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") runs = queue_all_mapped_reconciliations( db, tenant_id=scope.tenant_id, client_id=client.id, source_job_id=source_job_id, tally_guid=tally_guid, company_name=company_name, workstation_id=workstation_id, user_id=user.id, date_tolerance_days=date_tolerance_days, ) return _go( client.id, job_id=source_job_id, tally_guid=tally_guid, run_id=(runs[0].id if runs else None), message=f"Queued {len(runs)} reconciliation run(s), one for each mapped bank account.", ) except Exception as exc: db.rollback() return _go(client_id, job_id=source_job_id, tally_guid=tally_guid, error=str(exc)) finally: db.close() @router.post("/runs/{run_id}/items/{item_id}/resolve") def resolve_item( request: Request, run_id: int, item_id: int, client_id: int = Form(...), tally_guid: str = Form(""), action: str = Form(...), note: str = Form(""), csrf_token: str = Form(...), ): validate_csrf(request, csrf_token) db = CommonSessionLocal() try: user, denied = _require_partner(request, db, "accounting.learning.manage") if denied: return denied client, _clients, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") run = next( ( row for row in list_runs( db, tenant_id=scope.tenant_id, client_id=client.id, limit=100, ) if int(row.id) == int(run_id) ), None, ) if not run: raise ValueError("Reconciliation run was not found.") resolve_reconciliation_item( db, run_id=run.id, item_id=item_id, action=action, note=note, user_id=user.id, ) return _go( client.id, tally_guid=(tally_guid or run.tally_guid), run_id=run.id, message="Reconciliation review decision saved.", ) except Exception as exc: db.rollback() return _go(client_id, tally_guid=tally_guid, run_id=run_id, error=str(exc)) finally: db.close()