""" Receipt handlers for Telegram bot - OCR fiscal receipt flow. Handles: PDF/JPG document messages, photo messages, and receipt callback buttons. Flow: File received → OCR → Preview → Confirm/Cancel → Oracle write. """ import asyncio import logging import re import tempfile from datetime import date, datetime from pathlib import Path from typing import Optional import sqlite3 import oracledb from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.error import TelegramError from telegram.ext import ContextTypes from telegram.constants import ParseMode from backend.modules.telegram.db.operations import get_user, is_user_linked from backend.modules.data_entry.services.ocr.queue_client import submit_ocr_job, wait_for_result from backend.modules.data_entry.services.oracle_receipt_writer import write_receipt from backend.config import settings from shared.database.oracle_pool import oracle_pool logger = logging.getLogger(__name__) # ============================================================================ # MODULE CONSTANTS # ============================================================================ OCR_TIMEOUT_S = 120 OCR_POLL_INTERVAL_S = 1.0 PENDING_TTL_S = 600 LOW_CONFIDENCE_THRESHOLD = 0.60 TEMP_FILE_PREFIX = 'receipt_' # ============================================================================ # MODULE-LEVEL CACHES # ============================================================================ # Pending receipts: user_id -> {receipt_dict, temp_path, created_at} _pending_receipts: dict[int, dict] = {} # Track which write pools have been registered in oracle_pool _write_pool_registered: set[str] = set() # ============================================================================ # PRIVATE HELPERS # ============================================================================ def _is_pending_expired(pending: dict) -> bool: return (datetime.now() - pending['created_at']).total_seconds() > PENDING_TTL_S def _cleanup_expired_pending() -> None: """Remove expired pending receipts and their temp files.""" expired = [uid for uid, p in _pending_receipts.items() if _is_pending_expired(p)] for uid in expired: entry = _pending_receipts.pop(uid) try: Path(entry['temp_path']).unlink(missing_ok=True) except OSError: pass def _normalize_receipt_date(receipt_dict: dict) -> None: """Parse receipt_date in-place; fallback to today if None or malformed.""" raw = receipt_dict.get("receipt_date") try: if raw is None: receipt_dict["receipt_date"] = datetime.now().date() elif isinstance(raw, datetime): receipt_dict["receipt_date"] = raw.date() elif isinstance(raw, date): pass # already correct elif isinstance(raw, str): receipt_dict["receipt_date"] = date.fromisoformat(raw) else: receipt_dict["receipt_date"] = datetime.now().date() except (ValueError, TypeError): receipt_dict["receipt_date"] = datetime.now().date() def _build_oracle_config(server_id: Optional[str]) -> Optional[dict]: """Build Oracle connection config dict for a given server_id. Single Oracle user per server: read and write share credentials. """ srv = ( settings.get_oracle_server(server_id) if server_id else settings.get_default_oracle_server() ) if not srv: return None return { "user": srv.user, "password": srv.password, "dsn": srv.get_dsn(), } def _format_receipt_preview(receipt_dict: dict) -> str: """Format receipt data as a Telegram-friendly Markdown preview.""" partner = receipt_dict.get("partner_name") or "Necunoscut" cui = receipt_dict.get("cui") or "—" dt = receipt_dict.get("receipt_date") date_str = dt.strftime("%d.%m.%Y") if isinstance(dt, (date, datetime)) else str(dt or "—") amount = float(receipt_dict.get("amount") or 0) tva = float(receipt_dict.get("tva_total") or 0) nr = receipt_dict.get("receipt_number") or "—" return ( "📄 *Preview bon fiscal*\n\n" f"🏢 *Furnizor:* {partner}\n" f"🔢 *CUI:* {cui}\n" f"📅 *Data:* {date_str}\n" f"🔖 *Nr. bon:* {nr}\n" f"💰 *Total:* {amount:.2f} RON\n" f"🧾 *TVA:* {tva:.2f} RON\n" ) def _confidence_warning(confidence: Optional[float]) -> str: """Return a warning string when OCR confidence is below threshold.""" if confidence is not None and confidence < LOW_CONFIDENCE_THRESHOLD: return f"\n⚠ *Atenție:* Calitate OCR scăzută ({confidence:.0%}) — verificați datele!" return "" def _format_oracle_error(e: oracledb.DatabaseError) -> str: """Translate common ORA error codes to user-friendly Romanian messages.""" msg = str(e) if "ORA-01017" in msg: return "❌ Credențiale Oracle incorecte. Contactați administratorul." if "ORA-00001" in msg: return "❌ Bon duplicat — documentul există deja în sistem." if "ORA-12541" in msg: return "❌ Serverul Oracle nu este disponibil. Încercați mai târziu." return f"❌ Eroare Oracle: {msg}" # Oracle identifier rule: starts with letter/underscore, rest letters/digits/underscore. # We uppercase before matching since schemas are stored uppercase in v_nom_firme. _SCHEMA_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") def _sync_write_with_pool( receipt_dict: dict, pool: oracledb.ConnectionPool, ) -> tuple[int, str]: """Acquire a connection from the pool and write the receipt synchronously.""" conn = pool.acquire() try: return write_receipt(receipt_dict, conn) finally: conn.close() async def _resolve_schema(server_id: Optional[str], company_id: int) -> Optional[str]: """Look up Oracle schema for company_id via CONTAFIN_ORACLE.v_nom_firme.""" try: async with oracle_pool.get_connection(server_id) as conn: with conn.cursor() as cur: cur.execute( "SELECT schema FROM CONTAFIN_ORACLE.v_nom_firme WHERE id_firma = :id", id=company_id, ) row = cur.fetchone() if not row: return None schema = (row[0] or "").upper().strip() if not _SCHEMA_RE.match(schema): logger.warning("Invalid schema name from v_nom_firme: %r", schema) return None return schema except Exception as e: logger.warning("Schema lookup failed for company %s: %s", company_id, e) return None async def _save_to_oracle( receipt_dict: dict, oracle_cfg: dict, server_id: Optional[str] = None, schema: Optional[str] = None, ) -> tuple[int, str]: """Write receipt to Oracle as the schema owner (one Oracle user per schema). PACK_CONTAFIN's dynamic SQL uses SESSION_USER (not CURRENT_SCHEMA), so we must connect AS the schema owner. We assume the schema-owner Oracle user shares the read user's password (configured per-server in secrets/.oracle_pass). """ if schema: # Per-(server, schema) pool: each schema is a separate Oracle user. pool_key = f"{server_id or 'default'}_write_{schema}" if pool_key not in _write_pool_registered: srv = ( settings.get_oracle_server(server_id) if server_id else settings.get_default_oracle_server() ) if srv: oracle_pool.register_server( pool_key, host=srv.host, port=srv.port, user=schema, # connect AS schema owner password=oracle_cfg["password"], # same password as read user sid=srv.sid, service_name=srv.service_name, min_connections=1, max_connections=3, ) _write_pool_registered.add(pool_key) if oracle_pool.is_server_registered(pool_key): pool = await oracle_pool.get_pool(pool_key) return await asyncio.to_thread(_sync_write_with_pool, receipt_dict, pool) # Fallback: direct connection with the configured user (will likely fail # for receipt writes that need schema-owner privileges, but used by tests). return await asyncio.to_thread(write_receipt, receipt_dict, oracle_cfg) async def _submit_ocr_and_preview( update: Update, context: ContextTypes.DEFAULT_TYPE, temp_path: Path, user_id: int, ) -> None: """Submit file to OCR queue, wait for result, then show confirm/cancel preview.""" processing_msg = await update.message.reply_text( "⏳ Procesez bonul fiscal... (poate dura până la 2 minute)", parse_mode=ParseMode.MARKDOWN, ) try: job_id = await submit_ocr_job(temp_path) except (OSError, sqlite3.Error) as e: logger.error("OCR job submission failed for user %s: %s", user_id, e) await processing_msg.edit_text( "❌ Eroare internă la procesarea bonului.", parse_mode=ParseMode.MARKDOWN, ) temp_path.unlink(missing_ok=True) return try: result = await asyncio.wait_for( wait_for_result(job_id, timeout=OCR_TIMEOUT_S), timeout=OCR_TIMEOUT_S + 5, ) except asyncio.TimeoutError: await processing_msg.edit_text( "⏱ Timeout OCR — bonul nu a fost procesat la timp. Încercați din nou.", parse_mode=ParseMode.MARKDOWN, ) temp_path.unlink(missing_ok=True) return if not result.get("success"): error = result.get("error", "Eroare necunoscută") await processing_msg.edit_text( f"❌ OCR eșuat: {error}", parse_mode=ParseMode.MARKDOWN, ) temp_path.unlink(missing_ok=True) return receipt_dict = result["result"] or {} _normalize_receipt_date(receipt_dict) confidence = receipt_dict.get("confidence") _pending_receipts[user_id] = { "receipt_dict": receipt_dict, "temp_path": str(temp_path), "created_at": datetime.now(), } preview = _format_receipt_preview(receipt_dict) warning = _confidence_warning(confidence) keyboard = InlineKeyboardMarkup([ [ InlineKeyboardButton("✅ Confirmă", callback_data=f"receipt:confirm:{user_id}"), InlineKeyboardButton("❌ Anulează", callback_data=f"receipt:cancel:{user_id}"), ] ]) try: await processing_msg.edit_text( preview + warning + "\n\nConfirmați salvarea în Oracle?", parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard, ) except TelegramError as e: logger.error("Failed to send OCR preview for user %s: %s", user_id, e) _pending_receipts.pop(user_id, None) temp_path.unlink(missing_ok=True) # ============================================================================ # PUBLIC HANDLERS # ============================================================================ async def handle_document_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming PDF or image document messages.""" user_id = update.effective_user.id if not await is_user_linked(user_id): await update.message.reply_text( "❗ Cont neconectat. Folosiți /login pentru a vă autentifica.", parse_mode=ParseMode.MARKDOWN, ) return _cleanup_expired_pending() if user_id in _pending_receipts: await update.message.reply_text( "⚠ Ai un bon în așteptare. Confirmați sau anulați bonul anterior înainte de a trimite altul.", parse_mode=ParseMode.MARKDOWN, ) return doc = update.message.document suffix = Path(doc.file_name or "receipt.pdf").suffix or ".pdf" tmp = tempfile.NamedTemporaryFile(prefix=TEMP_FILE_PREFIX, suffix=suffix, delete=False) temp_path = Path(tmp.name) tmp.close() try: tg_file = await context.bot.get_file(doc.file_id) await tg_file.download_to_drive(temp_path) except OSError as e: logger.error("Failed to download document for user %s: %s", user_id, e) await update.message.reply_text("❌ Nu am putut descărca fișierul.") temp_path.unlink(missing_ok=True) return await _submit_ocr_and_preview(update, context, temp_path, user_id) async def handle_photo_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming photo messages.""" user_id = update.effective_user.id if not await is_user_linked(user_id): await update.message.reply_text( "❗ Cont neconectat. Folosiți /login pentru a vă autentifica.", parse_mode=ParseMode.MARKDOWN, ) return _cleanup_expired_pending() if user_id in _pending_receipts: await update.message.reply_text( "⚠ Ai un bon în așteptare. Confirmați sau anulați bonul anterior înainte de a trimite altul.", parse_mode=ParseMode.MARKDOWN, ) return photo = update.message.photo[-1] # largest available resolution tmp = tempfile.NamedTemporaryFile(prefix=TEMP_FILE_PREFIX, suffix=".jpg", delete=False) temp_path = Path(tmp.name) tmp.close() try: tg_file = await context.bot.get_file(photo.file_id) await tg_file.download_to_drive(temp_path) except OSError as e: logger.error("Failed to download photo for user %s: %s", user_id, e) await update.message.reply_text("❌ Nu am putut descărca fotografia.") temp_path.unlink(missing_ok=True) return await _submit_ocr_and_preview(update, context, temp_path, user_id) async def handle_receipt_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle receipt confirm/cancel callback queries (pattern: ^receipt:).""" query = update.callback_query await query.answer() parts = (query.data or "").split(":") if len(parts) != 3: await query.edit_message_text("❌ Cerere invalidă.") return _, action, target_uid_str = parts try: target_uid = int(target_uid_str) except ValueError: await query.edit_message_text("❌ Cerere invalidă.") return caller_uid = update.effective_user.id if caller_uid != target_uid: await query.answer("Nu ai permisiunea să acționezi pe bonul altcuiva.", show_alert=True) return pending = _pending_receipts.get(target_uid) if not pending: await query.edit_message_text("ℹ Bonul a expirat sau a fost deja procesat.") return if _is_pending_expired(pending): _pending_receipts.pop(target_uid, None) try: Path(pending['temp_path']).unlink(missing_ok=True) except OSError: pass await query.edit_message_text("⌛ Sesiune expirată. Retrimiteți bonul.") return if action == "cancel": _pending_receipts.pop(target_uid, None) try: Path(pending['temp_path']).unlink(missing_ok=True) except OSError: pass await query.edit_message_text("✖ Bon anulat. Fișierul a fost șters.") return if action != "confirm": await query.edit_message_text("❌ Acțiune necunoscută.") return # Confirm: build oracle config and write receipt user_data = await get_user(target_uid) if not user_data: await query.edit_message_text("❌ Utilizatorul nu a fost găsit.") return server_id = user_data.get("oracle_server_id") oracle_cfg = _build_oracle_config(server_id) if not oracle_cfg: await query.edit_message_text( "❌ Configurație Oracle lipsă. Contactați administratorul." ) return # Resolve schema from the user's active company so dynamic SQL inside # PACK_CONTAFIN resolves table names against the correct schema. from backend.modules.telegram.agent.session import get_session_manager session = await get_session_manager().get_or_create_session(target_uid) company = session.get_active_company() if not company or not company.get("id"): await query.edit_message_text( "❌ Selectează mai întâi o firmă cu /companies." ) return schema = await _resolve_schema(server_id, int(company["id"])) if not schema: await query.edit_message_text( f"❌ Schema firmei *{company.get('name') or company['id']}* nu a putut fi determinată.", parse_mode=ParseMode.MARKDOWN, ) return await query.edit_message_text("⏳ Salvez în Oracle...") receipt_dict = pending["receipt_dict"] temp_path_str = pending["temp_path"] try: cod, mesaj = await _save_to_oracle(receipt_dict, oracle_cfg, server_id, schema) _pending_receipts.pop(target_uid, None) try: Path(temp_path_str).unlink(missing_ok=True) except OSError: pass logger.info("Receipt saved: user=%s cod=%s mesaj=%r", target_uid, cod, mesaj) await query.edit_message_text( f"✅ Bon salvat! Cod document: *{cod}*\n_{mesaj}_", parse_mode=ParseMode.MARKDOWN, ) except oracledb.DatabaseError as e: logger.error("Oracle write error for user %s: %s", target_uid, e) await query.edit_message_text( _format_oracle_error(e), parse_mode=ParseMode.MARKDOWN, ) except asyncio.TimeoutError: logger.error("Oracle write timeout for user %s", target_uid) await query.edit_message_text( "⏱ Timeout la scriere Oracle. Încercați din nou.", parse_mode=ParseMode.MARKDOWN, )