Files
PecHub/worker/scripts/rebind_receipts.py
T
2026-06-18 16:31:29 +02:00

287 lines
9.9 KiB
Python

"""
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())