fix parsing ricevute
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
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())
|
||||
Reference in New Issue
Block a user