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:
41
backend/alembic.ini
Normal file
41
backend/alembic.ini
Normal 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
|
||||
@@ -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
54
backend/migrations/env.py
Normal 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()
|
||||
26
backend/migrations/script.py.mako
Normal file
26
backend/migrations/script.py.mako
Normal 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"}
|
||||
@@ -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')
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
145
backend/modules/data_entry/services/oracle_receipt_writer.py
Normal file
145
backend/modules/data_entry/services/oracle_receipt_writer.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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:
|
||||
|
||||
1
backend/modules/telegram/handlers/__init__.py
Normal file
1
backend/modules/telegram/handlers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Telegram bot handlers package."""
|
||||
504
backend/modules/telegram/handlers/receipt_handlers.py
Normal file
504
backend/modules/telegram/handlers/receipt_handlers.py
Normal 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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user