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>
This commit is contained in:
2026-06-05 09:26:58 +00:00
parent 8234103884
commit e257fa5d5f
35 changed files with 4531 additions and 227 deletions

41
backend/alembic.ini Normal file
View File

@@ -0,0 +1,41 @@
# Alembic configuration for Telegram module (app.db / SQLite)
[alembic]
script_location = migrations
file_template = %%(year)d%%(month).2d%%(day).2d_%%(slug)s
prepend_sys_path = .
sqlalchemy.url = sqlite:///data/app.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -314,9 +314,38 @@ async def run_telegram_bot():
error_handler
)
from backend.modules.telegram.bot.email_handlers import email_login_handler
# US-006: Receipt OCR flow handlers
from backend.modules.telegram.handlers.receipt_handlers import (
handle_document_message,
handle_photo_message,
handle_receipt_callback,
)
# Create Telegram application
application = Application.builder().token(settings.telegram_bot_token).build()
# US-006: Sweep orphan receipt temp files left over from a previous crash.
# Receipt OCR flow writes /tmp/receipt_*.* and unlinks them after
# confirm/cancel; if the bot died in between, those files remain on disk.
import glob as _glob
try:
orphans = 0
for _path_str in _glob.glob('/tmp/receipt_*.*'):
try:
Path(_path_str).unlink(missing_ok=True)
orphans += 1
except OSError as _e:
logger.warning(f"[TELEGRAM] Failed to unlink orphan {_path_str}: {_e}")
if orphans:
logger.info(f"[TELEGRAM] 🧹 Cleaned up {orphans} orphan receipt temp files")
except Exception as e:
logger.warning(f"[TELEGRAM] ⚠️ Receipt orphan cleanup failed (non-critical): {e}")
# Create Telegram application with concurrent_updates so multiple users
# can use the bot in parallel (e.g. two users uploading receipts at once).
application = (
Application.builder()
.token(settings.telegram_bot_token)
.concurrent_updates(True)
.build()
)
# Register handlers
application.add_handler(email_login_handler)
@@ -339,6 +368,20 @@ async def run_telegram_bot():
application.add_handler(CommandHandler("clearcache", clearcache_command))
application.add_handler(CommandHandler("togglecache", togglecache_command))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text_message))
# US-006: Receipt OCR handlers (PDF/JPG fiscal receipt flow).
# The receipt CallbackQueryHandler MUST be registered BEFORE the catch-all
# button_callback so `receipt:*` callbacks are routed correctly.
application.add_handler(MessageHandler(
filters.Document.PDF | filters.Document.IMAGE,
handle_document_message
))
application.add_handler(MessageHandler(filters.PHOTO, handle_photo_message))
application.add_handler(CallbackQueryHandler(
handle_receipt_callback,
pattern=r'^receipt:'
))
application.add_handler(CallbackQueryHandler(button_callback))
application.add_error_handler(error_handler)

54
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,54 @@
"""Alembic environment configuration for Telegram module (app.db)."""
from logging.config import fileConfig
from pathlib import Path
from sqlalchemy import engine_from_config, pool
from alembic import context
config = context.config
# Resolve DB path relative to this file: backend/migrations/env.py -> backend/data/app.db
db_path = Path(__file__).parent.parent / "data" / "app.db"
config.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Raw SQL tables — no SQLModel metadata
target_metadata = None
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,49 @@
"""Add oracle_server_id to telegram_users
Revision ID: 20260505_001
Revises:
Create Date: 2026-05-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision: str = '20260505_001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# Fresh installs: init_app_db() creates telegram_users with oracle_server_id already included.
# This migration only needs to act on existing DBs that pre-date the column.
table_exists = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='telegram_users'")
).fetchone()
if not table_exists:
return
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(telegram_users)")).fetchall()]
if 'oracle_server_id' not in cols:
with op.batch_alter_table('telegram_users') as batch_op:
batch_op.add_column(sa.Column('oracle_server_id', sa.Text(), nullable=True))
def downgrade() -> None:
conn = op.get_bind()
table_exists = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='telegram_users'")
).fetchone()
if not table_exists:
return
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(telegram_users)")).fetchall()]
if 'oracle_server_id' in cols:
with op.batch_alter_table('telegram_users') as batch_op:
batch_op.drop_column('oracle_server_id')

View File

@@ -3,6 +3,7 @@ from .receipt_service import ReceiptService
from .nomenclature_service import NomenclatureService
from .expense_types import EXPENSE_TYPES, ExpenseType
from .receipt_auto_create import ReceiptAutoCreateService, ReceiptCreateResult
from .oracle_receipt_writer import write_receipt
from . import sse_service
__all__ = [
@@ -12,5 +13,6 @@ __all__ = [
"ExpenseType",
"ReceiptAutoCreateService",
"ReceiptCreateResult",
"write_receipt",
"sse_service",
]

View File

@@ -257,18 +257,31 @@ def _decode_image(image_bytes: bytes) -> Optional[np.ndarray]:
# Try as PDF - use 200 DPI for faster processing (sufficient for receipts)
try:
import pdf2image
from PIL import Image
# 200 DPI is sufficient for receipt text recognition
# 300 DPI was overkill and slowed down processing
images = pdf2image.convert_from_bytes(image_bytes, dpi=200)
if images:
# Convert first page to numpy array
pil_img = images[0]
print(f"[Worker {os.getpid()}] PDF decoded: {pil_img.width}x{pil_img.height} @ 200 DPI", flush=True)
print(f"[Worker {os.getpid()}] PDF decoded (poppler): {pil_img.width}x{pil_img.height} @ 200 DPI", flush=True)
return np.array(pil_img)
except Exception as e:
print(f"[Worker {os.getpid()}] PDF decode error: {e}", flush=True)
# pdf2image needs poppler (pdftoppm/pdfinfo) on PATH; fall back to pypdfium2.
print(f"[Worker {os.getpid()}] pdf2image unavailable ({e}); trying pypdfium2 fallback...", flush=True)
try:
import pypdfium2 as pdfium
pdf = pdfium.PdfDocument(image_bytes)
if len(pdf) > 0:
page = pdf[0]
pil_img = page.render(scale=200 / 72).to_pil() # scale = DPI / 72
arr = np.array(pil_img)
if arr.ndim == 3 and arr.shape[2] == 4:
arr = arr[:, :, :3] # drop alpha
print(f"[Worker {os.getpid()}] PDF decoded (pypdfium2): {pil_img.width}x{pil_img.height} @ 200 DPI", flush=True)
return arr
except Exception as e2:
print(f"[Worker {os.getpid()}] pypdfium2 also failed: {e2}", flush=True)
return None

View File

@@ -1,4 +1,3 @@
#!/usr/bin/env python3
"""
Client pentru OCR API roa2web - adaugă job direct în SQLite queue.
Folosește aceeași coadă ca backend-ul, fără HTTP auth.
@@ -11,25 +10,23 @@ import uuid
from datetime import datetime, timedelta
from pathlib import Path
# Paths
QUEUE_DIR = Path("/workspace/roa2web/backend/data/ocr_queue")
QUEUE_DIR = Path(__file__).parents[4] / "data" / "ocr_queue"
DB_PATH = QUEUE_DIR / "ocr_jobs.db"
FILES_DIR = QUEUE_DIR / "files"
async def submit_ocr_job(file_path: Path, engine: str = "doctr_plus") -> str:
"""Submit OCR job to queue, return job_id."""
import aiosqlite
job_id = str(uuid.uuid4())
# Copy file to queue
FILES_DIR.mkdir(parents=True, exist_ok=True)
dest_path = FILES_DIR / f"{job_id}_{file_path.name}"
shutil.copy(file_path, dest_path)
# Determine mime type
mime_type = "application/pdf" if file_path.suffix.lower() == ".pdf" else "image/jpeg"
async with aiosqlite.connect(str(DB_PATH), timeout=5.0) as db:
await db.execute("""
INSERT INTO ocr_jobs (
@@ -42,13 +39,14 @@ async def submit_ocr_job(file_path: Path, engine: str = "doctr_plus") -> str:
(datetime.now() + timedelta(hours=24)).isoformat()
))
await db.commit()
return job_id
async def wait_for_result(job_id: str, timeout: int = 120) -> dict:
"""Wait for job completion and return result."""
import aiosqlite
start = datetime.now()
while (datetime.now() - start).seconds < timeout:
async with aiosqlite.connect(str(DB_PATH), timeout=5.0) as db:
@@ -58,31 +56,28 @@ async def wait_for_result(job_id: str, timeout: int = 120) -> dict:
(job_id,)
) as cursor:
row = await cursor.fetchone()
if row:
if row["status"] == "completed":
return {
"success": True,
"result": json.loads(row["result_json"]) if row["result_json"] else None,
"time_ms": row["processing_time_ms"]
}
elif row["status"] == "failed":
return {
"success": False,
"error": row["error_message"]
}
if row and row["status"] == "completed":
return {
"success": True,
"result": json.loads(row["result_json"]) if row["result_json"] else None,
"time_ms": row["processing_time_ms"]
}
if row and row["status"] == "failed":
return {"success": False, "error": row["error_message"]}
await asyncio.sleep(0.5)
return {"success": False, "error": "Timeout"}
async def process_file(file_path: Path):
"""Process file through OCR queue."""
print(f"[OCR Queue] Submitting: {file_path.name}")
job_id = await submit_ocr_job(file_path)
print(f"[OCR Queue] Job ID: {job_id}")
print(f"[OCR Queue] Waiting for result...")
result = await wait_for_result(job_id)
if result["success"]:
r = result["result"]
print(f"\n✅ OCR Complete ({result['time_ms']}ms)")
@@ -95,14 +90,15 @@ async def process_file(file_path: Path):
print(f"\n❌ Error: {result['error']}")
return None
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python roa2web_api_client.py <file_path>")
print("Usage: python queue_client.py <file_path>")
sys.exit(1)
file_path = Path(sys.argv[1])
if not file_path.exists():
print(f"File not found: {file_path}")
sys.exit(1)
asyncio.run(process_file(file_path))

View File

@@ -0,0 +1,145 @@
"""Shared helper for writing receipts to Oracle via PACK_CONTAFIN."""
import logging
from datetime import datetime
import oracledb
logger = logging.getLogger(__name__)
_CUI_TO_CONT = {
"11201891": "6022", # MOL
"1590082": "6022", # OMV Petrom
"14991381": "6022", # MOL Romania
"10562600": "6021", # Dedeman
}
def _get_cont_cheltuiala(cui: str) -> str:
cui_clean = cui.upper().replace("RO", "").strip()
return _CUI_TO_CONT.get(cui_clean, "6028")
def _build_conn_params(oracle_cfg) -> dict:
if isinstance(oracle_cfg, dict):
return oracle_cfg
return {
"user": oracle_cfg.user,
"password": oracle_cfg.password,
"dsn": oracle_cfg.get_dsn(),
}
def write_receipt(receipt_dict: dict, oracle_cfg, *, commit: bool = True) -> tuple[int, str]:
"""Write a receipt to Oracle ACT_TEMP via PACK_CONTAFIN.
Args:
receipt_dict: Keys: partner_name, cui, receipt_date, receipt_number, amount, tva_total
oracle_cfg: Dict with user/password/dsn, OracleServerConfig instance, or
oracledb.Connection (pre-acquired from pool — caller manages lifecycle)
commit: If False, rolls back after FINALIZEAZA (dry-run mode)
Returns:
(cod, mesaj) — Oracle document code and result message from PACK_CONTAFIN
"""
if isinstance(oracle_cfg, oracledb.Connection):
conn = oracle_cfg
own_conn = False
else:
conn_params = _build_conn_params(oracle_cfg)
conn = oracledb.connect(**conn_params)
own_conn = True
cursor = conn.cursor()
try:
receipt_date = receipt_dict.get("receipt_date")
_now = datetime.now()
an = receipt_date.year if receipt_date else _now.year
luna = receipt_date.month if receipt_date else _now.month
act_date = receipt_date or _now.date()
id_util = 0
id_sucursala = 0
cursor.callproc("PACK_CONTAFIN.INITIALIZEAZA_SCRIERE_ACT_RUL", [
id_util,
datetime.now(),
an,
luna,
0, # suprascriere_cod
0, # suprascriere_anluna
0, # scrie_sterge (0=scrie)
id_sucursala,
])
# Globally unique document COD from sequence (NOT MAX+1 per period —
# that races and reuses CODs across (AN, LUNA) tuples).
cursor.execute("SELECT seq_cod.nextval FROM DUAL")
cod = cursor.fetchone()[0]
cui = receipt_dict.get("cui") or ""
cui_clean = cui.upper().replace("RO", "").strip()
cursor.execute(
"SELECT ID_PART FROM NOM_PARTENERI WHERE COD_FISCAL = :cui OR COD_FISCAL = :cui2",
cui=cui_clean, cui2="RO" + cui_clean,
)
row = cursor.fetchone()
id_part = row[0] if row else 0
total = float(receipt_dict.get("amount") or 0)
tva = float(receipt_dict.get("tva_total") or 0)
fara_tva = total - tva
receipt_number = str(receipt_dict.get("receipt_number") or "")
nract = int(receipt_number) if receipt_number.isdigit() else 0
cont = _get_cont_cheltuiala(cui)
partner_name = receipt_dict.get("partner_name") or "N/A"
expl = f"OCR: {partner_name}"
lines = [
(cont, "401", fara_tva, expl, id_part, 0),
("401", "5311", total, f"Plata {expl}", 0, id_part),
]
if tva > 0:
lines.insert(1, ("4426", "401", tva, f"TVA {expl}", id_part, 0))
for scd, scc, suma, e, id_partc, id_partd in lines:
cursor.execute("""
INSERT INTO ACT_TEMP (
LUNA, AN, COD, DATAIREG, DATAACT, NRACT,
EXPLICATIA, SCD, SCC, SUMA,
ID_PARTC, ID_PARTD, ID_UTIL, DATAORA
) VALUES (
:luna, :an, :cod, TRUNC(SYSDATE), :dataact, :nract,
:expl, :scd, :scc, :suma,
:id_partc, :id_partd, :id_util, SYSDATE
)
""", luna=luna, an=an, cod=cod, dataact=act_date, nract=nract,
expl=e, scd=scd, scc=scc, suma=suma,
id_partc=id_partc, id_partd=id_partd, id_util=id_util)
mesaj_var = cursor.var(oracledb.STRING, 4000)
cursor.callproc("PACK_CONTAFIN.FINALIZEAZA_SCRIERE_ACT_RUL", [
id_util,
cod,
0, # scrie_sterge
0, # modificare_nota
0, # scrie_cump_vanz
mesaj_var,
])
mesaj = mesaj_var.getvalue() or ""
if commit:
conn.commit()
logger.info("write_receipt: saved COD=%s mesaj=%r", cod, mesaj)
else:
conn.rollback()
logger.info("write_receipt: dry-run rollback COD would be %s", cod)
return cod, mesaj
except Exception:
conn.rollback()
raise
finally:
cursor.close()
if own_conn:
conn.close()

View File

@@ -122,7 +122,8 @@ async def link_telegram_account(
oracle_username=oracle_username,
jwt_token=jwt_token,
jwt_refresh_token=jwt_refresh_token,
token_expires_at=token_expires_at
token_expires_at=token_expires_at,
server_id=server_id
)
if not linked:
@@ -235,7 +236,8 @@ async def get_user_auth_data(telegram_user_id: int) -> Optional[Dict[str, Any]]:
"username": oracle_username,
"jwt_token": jwt_token,
"jwt_refresh_token": jwt_refresh_token,
"companies": companies
"companies": companies,
"server_id": user_data.get('oracle_server_id')
}
except Exception as e:
@@ -325,6 +327,7 @@ async def unlink_user(telegram_user_id: int) -> bool:
await db.execute("""
UPDATE telegram_users
SET oracle_username = NULL,
oracle_server_id = NULL,
jwt_token = NULL,
jwt_refresh_token = NULL,
token_expires_at = NULL,

View File

@@ -6,6 +6,7 @@ using direct command handlers for financial data queries.
"""
import asyncio
import glob
import logging
import os
from pathlib import Path
@@ -68,6 +69,13 @@ from backend.modules.telegram.bot.handlers import (
# Import email authentication handler
from backend.modules.telegram.bot.email_handlers import email_login_handler
# Import receipt handlers (US-005: PDF/JPG OCR fiscal receipt flow)
from backend.modules.telegram.handlers.receipt_handlers import (
handle_document_message,
handle_photo_message,
handle_receipt_callback,
)
# Note: internal_api import removed - now served via main.py at /api/telegram/internal/*
# Configure logging
@@ -96,8 +104,14 @@ def create_telegram_application() -> Application:
"""
logger.info("Creating Telegram application...")
# Create application
application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
# Create application with concurrent_updates so multiple users can use the bot
# in parallel (e.g. two users uploading receipts simultaneously).
application = (
Application.builder()
.token(TELEGRAM_BOT_TOKEN)
.concurrent_updates(True)
.build()
)
# Register email authentication conversation handler (must be before other handlers)
application.add_handler(email_login_handler)
@@ -140,6 +154,19 @@ def create_telegram_application() -> Application:
handle_text_message
))
# US-006: Receipt handlers (PDF/JPG fiscal receipt OCR flow)
# IMPORTANT: receipt CallbackQueryHandler must be registered BEFORE the
# catch-all button_callback so `receipt:*` callbacks are routed correctly.
application.add_handler(MessageHandler(
filters.Document.PDF | filters.Document.IMAGE,
handle_document_message
))
application.add_handler(MessageHandler(filters.PHOTO, handle_photo_message))
application.add_handler(CallbackQueryHandler(
handle_receipt_callback,
pattern=r'^receipt:'
))
# FAZA 4: Register callback query handler (for inline buttons)
application.add_handler(CallbackQueryHandler(button_callback))
@@ -156,12 +183,39 @@ def create_telegram_application() -> Application:
# ============================================================================
# Note: Internal API server removed - now served via main.py at /api/telegram/internal/*
def startup_cleanup() -> int:
"""
Remove orphan receipt temp files left over from a previous bot crash.
Receipt OCR flow writes downloaded files to `/tmp/receipt_*.*` and unlinks
them after confirm/cancel. If the bot died between download and cleanup,
those files remain on disk; we clean them on startup. Returns the count
of unlinked files (for logging).
"""
count = 0
for path_str in glob.glob('/tmp/receipt_*.*'):
try:
Path(path_str).unlink(missing_ok=True)
count += 1
except OSError as e:
logger.warning(f"Failed to unlink orphan receipt file {path_str}: {e}")
return count
async def startup():
"""
Initialize the bot application on startup.
"""
logger.info("🚀 ROA2WEB Telegram Bot - Starting up...")
# US-006: Sweep orphan receipt temp files from previous crashes
try:
orphans = startup_cleanup()
if orphans:
logger.info(f"🧹 Cleaned up {orphans} orphan receipt temp files")
except Exception as e:
logger.warning(f"⚠️ Receipt orphan cleanup failed (non-critical): {e}")
# Initialize database
try:
logger.info("Initializing SQLite database...")

View File

@@ -100,7 +100,8 @@ async def link_user_to_oracle(
oracle_username: str,
jwt_token: str,
jwt_refresh_token: str,
token_expires_at: datetime
token_expires_at: datetime,
server_id: Optional[str] = None
) -> bool:
"""
Link a Telegram user to an Oracle account and save JWT tokens.
@@ -111,6 +112,7 @@ async def link_user_to_oracle(
jwt_token: JWT access token
jwt_refresh_token: JWT refresh token
token_expires_at: Token expiration timestamp
server_id: Oracle server ID for multi-server mode
Returns:
bool: True if successful
@@ -122,6 +124,7 @@ async def link_user_to_oracle(
await db.execute("""
UPDATE telegram_users
SET oracle_username = ?,
oracle_server_id = ?,
jwt_token = ?,
jwt_refresh_token = ?,
token_expires_at = ?,
@@ -130,6 +133,7 @@ async def link_user_to_oracle(
WHERE telegram_user_id = ?
""", (
oracle_username,
server_id,
jwt_token,
jwt_refresh_token,
token_expires_at,
@@ -138,7 +142,7 @@ async def link_user_to_oracle(
))
await db.commit()
logger.info(f"User {telegram_user_id} linked to Oracle user {oracle_username}")
logger.info(f"User {telegram_user_id} linked to Oracle user {oracle_username} (server_id={server_id})")
return True
except Exception as e:

View File

@@ -0,0 +1 @@
"""Telegram bot handlers package."""

View File

@@ -0,0 +1,504 @@
"""
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,
)

View File

@@ -7,28 +7,17 @@ import sys
sys.path.insert(0, "/workspace/roa2web")
import asyncio
import oracledb
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from backend.modules.data_entry.services.oracle_receipt_writer import write_receipt
ORACLE_CONFIG = {
"user": "MARIUSM_AUTO",
"password": "ROMFASTSOFT",
"dsn": "10.0.20.121:1521/ROA"
}
CUI_TO_CONT = {
"11201891": "6022", # MOL
"1590082": "6022", # OMV Petrom
"14991381": "6022", # MOL Romania
"10562600": "6021", # Dedeman
}
def get_cont_cheltuiala(cui: str) -> str:
cui_clean = cui.upper().replace("RO", "").strip()
return CUI_TO_CONT.get(cui_clean, "6028")
async def process_pdf(pdf_path: Path):
from backend.modules.data_entry.services.ocr_service import ocr_service
@@ -57,109 +46,27 @@ def save_to_oracle_with_pack(result, do_commit: bool = False):
mode = "SAVE" if do_commit else "DRY RUN"
print(f"\n[Oracle + PACK_CONTAFIN] {mode}")
print("-" * 50)
conn = oracledb.connect(**ORACLE_CONFIG)
cursor = conn.cursor()
receipt_dict = {
"partner_name": result.partner_name,
"cui": result.cui,
"receipt_date": result.receipt_date,
"receipt_number": result.receipt_number,
"amount": result.amount,
"tva_total": result.tva_total,
}
try:
an = result.receipt_date.year if result.receipt_date else datetime.now().year
luna = result.receipt_date.month if result.receipt_date else datetime.now().month
receipt_date = result.receipt_date or datetime.now().date()
# Parametri
id_util = 0 # ID utilizator implicit
id_sucursala = 0
# 1. INITIALIZEAZA
print("[1] INITIALIZEAZA_SCRIERE_ACT_RUL...")
cursor.callproc('PACK_CONTAFIN.INITIALIZEAZA_SCRIERE_ACT_RUL', [
id_util,
datetime.now(),
an,
luna,
0, # suprascriere_cod
0, # suprascriere_anluna
0, # scrie_sterge (0=scrie)
id_sucursala
])
print(" OK")
# Obține COD din secvență sau calculează
cursor.execute("SELECT NVL(MAX(COD), 0) + 1 FROM ACT WHERE AN = :an AND LUNA = :luna", an=an, luna=luna)
cod = cursor.fetchone()[0]
# Partner
cui_clean = (result.cui or "").upper().replace("RO", "").strip()
cursor.execute("SELECT ID_PART FROM NOM_PARTENERI WHERE COD_FISCAL = :cui OR COD_FISCAL = :cui2",
cui=cui_clean, cui2="RO"+cui_clean)
row = cursor.fetchone()
id_part = row[0] if row else 0
total = float(result.amount or 0)
tva = float(result.tva_total or 0)
fara_tva = total - tva
nract = int(result.receipt_number) if result.receipt_number and result.receipt_number.isdigit() else 0
cont = get_cont_cheltuiala(result.cui or "")
expl = f"OCR: {result.partner_name or 'N/A'}"
print(f" COD: {cod}, Partner ID: {id_part}, Cont: {cont}")
# 2. INSERT în ACT_TEMP
print("[2] INSERT ACT_TEMP...")
lines = [
(cont, "401", fara_tva, expl, id_part, 0), # cheltuială - partener pe credit
("401", "5311", total, f"Plata {expl}", 0, id_part), # plată - partener pe debit
]
if tva > 0:
lines.insert(1, ("4426", "401", tva, f"TVA {expl}", id_part, 0)) # TVA - partener pe credit
for scd, scc, suma, e, id_partc, id_partd in lines:
cursor.execute("""
INSERT INTO ACT_TEMP (
LUNA, AN, COD, DATAIREG, DATAACT, NRACT,
EXPLICATIA, SCD, SCC, SUMA,
ID_PARTC, ID_PARTD, ID_UTIL, DATAORA
) VALUES (
:luna, :an, :cod, TRUNC(SYSDATE), :dataact, :nract,
:expl, :scd, :scc, :suma,
:id_partc, :id_partd, :id_util, SYSDATE
)
""", luna=luna, an=an, cod=cod, dataact=receipt_date, nract=nract,
expl=e, scd=scd, scc=scc, suma=suma,
id_partc=id_partc, id_partd=id_partd, id_util=id_util)
print(f" {scd} = {scc}: {suma:.2f}")
# 3. FINALIZEAZA
print("[3] FINALIZEAZA_SCRIERE_ACT_RUL...")
mesaj = cursor.var(oracledb.STRING, 4000)
cursor.callproc('PACK_CONTAFIN.FINALIZEAZA_SCRIERE_ACT_RUL', [
id_util,
cod,
0, # scrie_sterge
0, # modificare_nota
0, # scrie_cump_vanz
mesaj
])
result_msg = mesaj.getvalue()
print(f" Mesaj: {result_msg}")
cod, mesaj = write_receipt(receipt_dict, ORACLE_CONFIG, commit=do_commit)
print(f" Mesaj: {mesaj}")
if do_commit:
conn.commit()
print(f"\n✅ SALVAT în Oracle (COD={cod})")
else:
conn.rollback()
print(f"\n⚠️ DRY RUN - rollback (COD ar fi fost {cod})")
return cod, result_msg
return cod, mesaj
except Exception as e:
print(f"❌ Eroare: {e}")
conn.rollback()
raise
finally:
cursor.close()
conn.close()
async def main():
if len(sys.argv) < 2:

View File

@@ -8,10 +8,10 @@ import json
import shutil
import sys
import uuid
import oracledb
from datetime import datetime, timedelta
from pathlib import Path
from decimal import Decimal
from backend.modules.data_entry.services.oracle_receipt_writer import write_receipt
# OCR Queue paths
QUEUE_DIR = Path("/workspace/roa2web/backend/data/ocr_queue")
@@ -25,16 +25,6 @@ ORACLE_CONFIG = {
"dsn": "10.0.20.121:1521/ROA"
}
CUI_TO_CONT = {
"11201891": "6022",
"1590082": "6022",
"14991381": "6022",
"10562600": "6021",
}
def get_cont(cui: str) -> str:
return CUI_TO_CONT.get(cui.upper().replace("RO", "").strip(), "6028")
async def submit_ocr_job(file_path: Path) -> str:
import aiosqlite
job_id = str(uuid.uuid4())
@@ -43,13 +33,14 @@ async def submit_ocr_job(file_path: Path) -> str:
shutil.copy(file_path, dest_path)
mime_type = "application/pdf" if file_path.suffix.lower() == ".pdf" else "image/jpeg"
now = datetime.now()
async with aiosqlite.connect(str(DB_PATH), timeout=5.0) as db:
await db.execute("""
INSERT INTO ocr_jobs (id, status, file_path, mime_type, engine, created_at, original_filename, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (job_id, "pending", str(dest_path), mime_type, "doctr_plus",
datetime.now().isoformat(), file_path.name,
(datetime.now() + timedelta(hours=24)).isoformat()))
now.isoformat(), file_path.name,
(now + timedelta(hours=24)).isoformat()))
await db.commit()
return job_id
@@ -72,71 +63,20 @@ async def wait_for_result(job_id: str, timeout: int = 120) -> dict:
return {"success": False, "error": "Timeout"}
def save_to_oracle(ocr_result: dict, do_commit: bool = False) -> dict:
conn = oracledb.connect(**ORACLE_CONFIG)
cursor = conn.cursor()
try:
# Parse date
date_str = ocr_result.get("receipt_date")
if date_str:
receipt_date = datetime.strptime(date_str[:10], "%Y-%m-%d").date()
else:
receipt_date = datetime.now().date()
an, luna = receipt_date.year, receipt_date.month
# Init
cursor.callproc('PACK_CONTAFIN.INITIALIZEAZA_SCRIERE_ACT_RUL', [0, datetime.now(), an, luna, 0, 0, 0, 0])
# Get COD
cursor.execute("SELECT NVL(MAX(COD), 0) + 1 FROM ACT WHERE AN = :an AND LUNA = :luna", an=an, luna=luna)
cod = cursor.fetchone()[0]
# Partner
cui_clean = (ocr_result.get("cui") or "").upper().replace("RO", "").strip()
cursor.execute("SELECT ID_PART FROM NOM_PARTENERI WHERE COD_FISCAL = :cui OR COD_FISCAL = :cui2",
cui=cui_clean, cui2="RO"+cui_clean)
row = cursor.fetchone()
id_part = row[0] if row else 0
# Amounts
total = float(ocr_result.get("amount") or 0)
tva = float(ocr_result.get("tva_total") or 0)
fara_tva = total - tva
nract = int(ocr_result.get("receipt_number") or 0) if str(ocr_result.get("receipt_number", "")).isdigit() else 0
cont = get_cont(ocr_result.get("cui") or "")
expl = f"OCR: {ocr_result.get('partner_name') or 'N/A'}"
# Insert lines
lines = [
(cont, "401", fara_tva, expl, id_part, 0),
("401", "5311", total, f"Plata {expl}", 0, id_part),
]
if tva > 0:
lines.insert(1, ("4426", "401", tva, f"TVA {expl}", id_part, 0))
for scd, scc, suma, e, id_partc, id_partd in lines:
cursor.execute("""
INSERT INTO ACT_TEMP (LUNA, AN, COD, DATAIREG, DATAACT, NRACT, EXPLICATIA, SCD, SCC, SUMA, ID_PARTC, ID_PARTD, ID_UTIL, DATAORA)
VALUES (:luna, :an, :cod, TRUNC(SYSDATE), :dataact, :nract, :expl, :scd, :scc, :suma, :id_partc, :id_partd, 0, SYSDATE)
""", luna=luna, an=an, cod=cod, dataact=receipt_date, nract=nract, expl=e, scd=scd, scc=scc, suma=suma, id_partc=id_partc, id_partd=id_partd)
# Finalize
mesaj = cursor.var(oracledb.STRING, 4000)
cursor.callproc('PACK_CONTAFIN.FINALIZEAZA_SCRIERE_ACT_RUL', [0, cod, 0, 0, 0, mesaj])
if do_commit:
conn.commit()
return {"success": True, "cod": cod, "luna": luna, "an": an, "saved": True}
else:
conn.rollback()
return {"success": True, "cod": cod, "luna": luna, "an": an, "saved": False}
receipt_date = (
datetime.strptime(date_str[:10], "%Y-%m-%d").date() if date_str else None
)
effective_date = receipt_date or datetime.now().date()
an, luna = effective_date.year, effective_date.month
receipt_dict = {**ocr_result, "receipt_date": receipt_date}
cod, _mesaj = write_receipt(receipt_dict, ORACLE_CONFIG, commit=do_commit)
return {"success": True, "cod": cod, "luna": luna, "an": an, "saved": do_commit}
except Exception as e:
conn.rollback()
return {"success": False, "error": str(e)}
finally:
cursor.close()
conn.close()
async def process_whatsapp_file(file_path: Path, do_save: bool = False):
print(f"📄 Procesez: {file_path.name}")