""" Script di binding retroattivo per le ricevute PEC orfane. Problema risolto: La race condition tra send_pec (che committava message_id_header DOPO l'invio SMTP) e il ciclo IMAP sync (che processava le ricevute di accettazione arrivate in pochi secondi) causava il salvataggio di ricevute con parent_message_id=NULL. Queste ricevute "orfane" apparivano in inbox come messaggi normali. Questo script: 1. Trova tutte le ricevute inbound (pec_type != 'posta_certificata') con parent_message_id IS NULL e riferimento_message_id valorizzato. 2. Per ciascuna cerca il messaggio outbound con message_id_header corrispondente nello stesso tenant. 3. Se trovato, aggiorna parent_message_id e applica la state machine outbound. Esecuzione: # Sul server remoto: docker exec pechub-worker-1 python scripts/rebind_receipts.py # Con dry-run (solo stampa cosa farebbe, senza modificare il DB): docker exec pechub-worker-1 python scripts/rebind_receipts.py --dry-run # Limita ai messaggi degli ultimi N giorni: docker exec pechub-worker-1 python scripts/rebind_receipts.py --days 30 # Stampa statistiche finali: docker exec pechub-worker-1 python scripts/rebind_receipts.py --verbose """ import argparse import asyncio import logging import sys import os from datetime import UTC, datetime, timedelta # Aggiunge la root del worker al path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from sqlalchemy import and_, select, text from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from app.models import Message from app.parsers.pec_parser import apply_outbound_transition logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) logger = logging.getLogger("rebind_receipts") # Tipi di ricevuta che non sono posta_certificata RECEIPT_PEC_TYPES = { "accettazione", "non_accettazione", "presa_in_carico", "avvenuta_consegna", "mancata_consegna", "errore_consegna", "preavviso_mancata_consegna", "rilevazione_virus", } async def rebind_receipts( db: AsyncSession, dry_run: bool = False, days: int | None = None, verbose: bool = False, ) -> dict: """ Esegue il binding retroattivo delle ricevute orfane. Args: db: sessione DB asincrona dry_run: se True, non modifica il DB (solo log) days: se impostato, processa solo ricevute degli ultimi N giorni verbose: log dettagliato per ogni ricevuta Returns: dict con statistiche: total, bound, already_bound, not_found, errors """ stats = { "total": 0, "bound": 0, "already_bound": 0, "not_found": 0, "errors": 0, "state_transitions": 0, } # ── Costruisci query base ───────────────────────────────────────────────── # Ricevute orfane: parent_message_id IS NULL ma riferimento_message_id valorizzato conditions = [ Message.direction == "inbound", Message.pec_type.in_(list(RECEIPT_PEC_TYPES)), Message.parent_message_id.is_(None), Message.riferimento_message_id.isnot(None), ] if days is not None: cutoff = datetime.now(UTC) - timedelta(days=days) conditions.append(Message.created_at >= cutoff) result = await db.execute( select(Message) .where(and_(*conditions)) .order_by(Message.created_at.asc()) ) orphan_receipts = list(result.scalars().all()) stats["total"] = len(orphan_receipts) logger.info( f"Trovate {stats['total']} ricevute orfane da processare" + (f" (ultimi {days} giorni)" if days else "") + (" [DRY-RUN]" if dry_run else "") ) if not orphan_receipts: logger.info("Nessuna ricevuta orfana trovata.") return stats # ── Processa ogni ricevuta ──────────────────────────────────────────────── for receipt in orphan_receipts: try: riferimento = receipt.riferimento_message_id if verbose: logger.info( f"Elaboro ricevuta {receipt.id} " f"pec_type={receipt.pec_type!r} " f"riferimento={riferimento!r}" ) # Cerca il messaggio outbound corrispondente nello stesso tenant outbound_result = await db.execute( select(Message).where( Message.tenant_id == receipt.tenant_id, Message.message_id_header == riferimento, Message.direction == "outbound", ) ) candidates = list(outbound_result.scalars().all()) if not candidates: if verbose: logger.warning( f" Nessun outbound trovato per riferimento={riferimento!r}" ) stats["not_found"] += 1 continue # Prioritizza il record con imap_uid=NULL (canonico di send_pec) parent_msg: Message | None = None if len(candidates) == 1: parent_msg = candidates[0] else: for m in candidates: if m.imap_uid is None: parent_msg = m break if parent_msg is None: parent_msg = candidates[0] if verbose: logger.info( f" Trovato outbound {parent_msg.id} " f"state={parent_msg.state!r} " f"(da {len(candidates)} candidati)" ) # Aggiorna parent_message_id sulla ricevuta if not dry_run: receipt.parent_message_id = parent_msg.id receipt.updated_at = datetime.now(UTC) stats["bound"] += 1 # Applica state machine outbound new_state = apply_outbound_transition(parent_msg.state, receipt.pec_type) if new_state and not dry_run: old_state = parent_msg.state parent_msg.state = new_state parent_msg.updated_at = datetime.now(UTC) stats["state_transitions"] += 1 logger.info( f" State machine: {parent_msg.id} {old_state!r} -> {new_state!r} " f"(ricevuta: {receipt.pec_type!r})" ) elif new_state: # Dry-run: solo log logger.info( f" [DRY-RUN] State machine applicherebbe: " f"{parent_msg.id} {parent_msg.state!r} -> {new_state!r}" ) logger.info( f"{'[DRY-RUN] ' if dry_run else ''}" f"Bindato: ricevuta {receipt.id} ({receipt.pec_type}) " f"-> outbound {parent_msg.id}" ) except Exception as e: logger.error( f"Errore processando ricevuta {receipt.id}: {e}", exc_info=True, ) stats["errors"] += 1 continue # ── Commit se non dry-run ───────────────────────────────────────────────── if not dry_run and stats["bound"] > 0: try: await db.commit() logger.info(f"Commit eseguito: {stats['bound']} ricevute bindato") except Exception as e: logger.error(f"Errore durante il commit: {e}", exc_info=True) await db.rollback() stats["errors"] += stats["bound"] stats["bound"] = 0 return stats async def main() -> None: parser = argparse.ArgumentParser( description="Binding retroattivo delle ricevute PEC orfane" ) parser.add_argument( "--dry-run", action="store_true", help="Mostra cosa verrebbe fatto senza modificare il DB", ) parser.add_argument( "--days", type=int, default=None, metavar="N", help="Processa solo le ricevute degli ultimi N giorni (default: tutte)", ) parser.add_argument( "--verbose", action="store_true", help="Log dettagliato per ogni ricevuta", ) args = parser.parse_args() # ── Connessione DB ──────────────────────────────────────────────────────── from app.config import get_settings settings = get_settings() engine = create_async_engine(settings.database_url, echo=False) SessionLocal = async_sessionmaker(engine, expire_on_commit=False) logger.info("=== Rebind ricevute PEC orfane ===") if args.dry_run: logger.info("MODALITA' DRY-RUN: nessuna modifica al DB") try: async with SessionLocal() as db: stats = await rebind_receipts( db=db, dry_run=args.dry_run, days=args.days, verbose=args.verbose, ) finally: await engine.dispose() # ── Riepilogo ───────────────────────────────────────────────────────────── print("\n=== Riepilogo ===") print(f"Ricevute orfane trovate: {stats['total']}") print(f"Bindato con successo: {stats['bound']}") print(f"Outbound non trovato: {stats['not_found']}") print(f"Transizioni stato: {stats['state_transitions']}") print(f"Errori: {stats['errors']}") if args.dry_run: print("\n[DRY-RUN] Nessuna modifica al DB effettuata.") if stats["errors"] > 0: sys.exit(1) if __name__ == "__main__": asyncio.run(main())