from __future__ import annotations 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_service import ( ai_assist_transaction, confirm_review, import_completed_job, internal_model_predict_transaction, preflight_choices, queue_post, queue_preflight, queue_rows, sync_posting, visible_workstations, ) from app.modules.accounting.ledger_learning_service import active_natures from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients from app.modules.bank_statement_analyzer.models import BankStatementAnalysisJob from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from sqlalchemy import select router = APIRouter(prefix="/tools/accounting/bank-posting", tags=["accounting-bank-posting-ui"]) def _go(client_id=0, message="", error=""): q = {"client_id": client_id} if client_id else {} if message: q["message"] = message[:250] if error: q["error"] = error[:250] return RedirectResponse("/tools/accounting/bank-posting" + ("?" + urlencode(q) if q else ""), status_code=303) @router.get("") def page(request: Request, client_id: int | None = None, status: str = "", page: int = 1, per_page: int = 25, 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) rows = []; total = 0; pages = 1 choices = {}; workstations = [] if selected: rows, total, page, pages = queue_rows(db, tenant_id=scope.tenant_id, client_id=selected.id, status=status, page=page, per_page=per_page) for row in rows: sync_posting(db, row) choices = {r.id: preflight_choices(r) for r in rows if r.preflight_result_json} workstations = visible_workstations(db, scope.tenant_id, getattr(user, "branch_id", None)) jobs = list(db.execute(select(BankStatementAnalysisJob).where( BankStatementAnalysisJob.user_id == user.id, BankStatementAnalysisJob.status == "completed", ).order_by(BankStatementAnalysisJob.completed_at_utc.desc()).limit(30)).scalars().all()) return templates.TemplateResponse("modules/accounting/templates/accounting/bank_posting.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 to Tally", "clients": clients, "selected_client": selected, "rows": rows, "total": total, "page": page, "pages": pages, "per_page": per_page, "status_filter": status, "jobs": jobs, "natures": active_natures(db, scope.tenant_id), "choices": choices, "workstations": workstations, "message": message, "error": error, }) finally: db.close() @router.post("/import") def import_job(request: Request, client_id: int = Form(...), job_id: 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, _, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") added, skipped = import_completed_job(db, tenant_id=scope.tenant_id, client_id=client.id, job_id=job_id, user_id=user.id) return _go(client.id, message=f"Imported {added} bank transaction(s); {skipped} duplicate fingerprint(s) skipped.") except Exception as exc: db.rollback(); return _go(client_id, error=str(exc)) finally: db.close() @router.post("/{tx_id}/review") def review(request: Request, tx_id: int, client_id: int = Form(...), nature_id: int | None = Form(None), ledger_name: str = Form(""), voucher_type: str = Form(...), review_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, _, scope = _find_visible_client(db, request, user, client_id) confirm_review(db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, nature_id=nature_id, ledger_name=ledger_name, voucher_type=voucher_type, user_id=user.id, note=review_note) return _go(client.id, message="Bank transaction review saved.") except Exception as exc: db.rollback(); return _go(client_id, error=str(exc)) finally: db.close() @router.post("/{tx_id}/preflight") def preflight(request: Request, tx_id: int, client_id: int = Form(...), 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, _, scope = _find_visible_client(db, request, user, client_id) queue_preflight(db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, workstation_id=workstation_id, user_id=user.id) return _go(client.id, message="Bank posting preflight queued.") except Exception as exc: db.rollback(); return _go(client_id, error=str(exc)) finally: db.close() @router.post("/{tx_id}/post") def post(request: Request, tx_id: int, client_id: int = Form(...), bank_ledger_name: str = Form(...), counter_ledger_name: str = Form(""), other_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, _, scope = _find_visible_client(db, request, user, client_id) queue_post(db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, bank_ledger_name=bank_ledger_name, counter_ledger_name=counter_ledger_name, other_bank_ledger_name=other_bank_ledger_name, user_id=user.id) return _go(client.id, message="Controlled bank voucher job queued.") except Exception as exc: db.rollback(); return _go(client_id, error=str(exc)) finally: db.close() @router.post("/{tx_id}/ai-assist") def ai_assist( request: Request, tx_id: int, client_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, _, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") decision = ai_assist_transaction( db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, user_id=user.id, ) return _go( client.id, message=f"AI semantic fallback completed at {decision.combined_confidence}% combined confidence.", ) except Exception as exc: db.rollback() return _go(client_id, error=str(exc)) finally: db.close() @router.post("/{tx_id}/internal-predict") def internal_predict( request: Request, tx_id: int, client_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, _, scope = _find_visible_client(db, request, user, client_id) if not client: return _go(error="Client is not visible.") prediction = internal_model_predict_transaction( db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, force_shadow=True, ) return _go( client.id, message=f"Internal model shadow prediction recorded at {prediction.predicted_probability * 100:.1f}%.", ) except Exception as exc: db.rollback() return _go(client_id, error=str(exc)) finally: db.close()