Files
roa2web-service-auto/backend/modules/telegram/handlers/receipt_handlers.py
Marius Mutu e257fa5d5f feat(telegram): bot bonuri fiscale — OCR → preview → Oracle write
- US-001: mută queue_client.py în data_entry/services/ocr/
- US-002/003/004: oracle_receipt_writer + oracle_server_id în DB
- US-005: receipt_handlers.py (PDF/photo/callback flow)
- US-006: wire handlers în main.py, per-schema connect, seq_cod.nextval
- US-007: .gitignore secrets/*.oracle_pass
- US-008/009/010: teste unit + integration + E2E
- setup-secrets.sh helper + template
- docs/telegram/README.md actualizat cu arhitectura nouă

Testat E2E pe DB live (MARIUSM_AUTO). COD din seq_cod.nextval.
pypdfium2 fallback pentru PDF decode (fără poppler).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 09:26:58 +00:00

505 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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/<id>.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,
)