- 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>
505 lines
18 KiB
Python
505 lines
18 KiB
Python
"""
|
||
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,
|
||
)
|