287 lines
9.9 KiB
Python
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())
|