"""Logique d'import PDF → base de données avec déduplication.""" from dataclasses import dataclass, field from pathlib import Path from sqlalchemy import select from sqlalchemy.orm import Session from src.db import ( Absence, Apprenti, EscadaPending, Import, find_or_create_apprenti, upsert_escada_pending, ) from src.parser import parse_pdf @dataclass class ImportResult: import_id: int classe: str semestre: str fichier: str nb_apprentis: int nb_absences_nouvelles: int nb_absences_doublons: int nb_absences_mises_a_jour: int = 0 nb_absences_supprimees: int = 0 # orphelines : abs en DB plus présentes dans le PDF Escada nb_pendings_orphelins_supprimes: int = 0 # pendings supprimés en cascade avec orphelines nb_absences_pending_skipped: int = 0 # abs non modifiées car pending modif locale details_nouvelles: list[str] = field(default_factory=list) details_mises_a_jour: list[str] = field(default_factory=list) details_orphelines: list[str] = field(default_factory=list) def import_pdf( pdf_path: str | Path, session: Session, imported_by: str = "system", force: bool = False, ) -> ImportResult: """Parse un PDF et insère les absences dans la DB. Déduplication : (apprenti_id, date, periode) déjà présent → ignoré. Si force=True, réinitialise le statut de toutes les absences existantes (même type inchangé) et efface les EscadaPending associés. """ pdf_path = Path(pdf_path) data = parse_pdf(pdf_path) classe = data["classe"] semestre = data["semestre"] apprentis_data = data["apprentis"] # Garde-fou : on n'importe JAMAIS d'absences pour les classes MP/MI. # Les MP servent uniquement au matching Matu (via NotesMatu, lié à des # apprentis déjà présents dans une classe régulière). Les MI sont # totalement ignorées. if classe.startswith(("MP", "MI")): return ImportResult( import_id=0, classe=classe, semestre=semestre, fichier=pdf_path.name, nb_apprentis=0, nb_absences_nouvelles=0, nb_absences_doublons=0, ) nb_nouvelles = 0 nb_doublons = 0 nb_mises_a_jour = 0 nb_supprimees = 0 nb_pending_skipped = 0 # Détails par apprenti : {apprenti_id: {"nom": str, "prenom": str, "dates": [str]}} _nouv_by_ap: dict[int, dict] = {} _upd_by_ap: dict[int, dict] = {} # Crée l'enregistrement d'import en premier pour avoir son id import_rec = Import( fichier=pdf_path.name, classe=classe, semestre=semestre, nb_apprentis=len(apprentis_data), imported_by=imported_by, ) session.add(import_rec) session.flush() # génère import_rec.id # Clés (apprenti_id, date, periode) vues dans le nouveau PDF seen_keys: set[tuple] = set() for a_data in apprentis_data: try: apprenti = find_or_create_apprenti( session, a_data["nom"], a_data["prenom"], a_data["classe"] ) except ValueError: # Apprenti rejeté par le garde-fou (classe vide / MP / MI) : # on saute cette page du PDF sans interrompre tout l'import. continue for ab in a_data["absences"]: key = (apprenti.id, ab["date"], ab["periode"]) seen_keys.add(key) existe = session.execute( select(Absence).where( Absence.apprenti_id == apprenti.id, Absence.date == ab["date"], Absence.periode == ab["periode"], ) ).scalar_one_or_none() ep_pending = session.execute( select(EscadaPending).where( EscadaPending.apprenti_id == apprenti.id, EscadaPending.date == ab["date"], EscadaPending.periode == ab["periode"], ) ).scalar_one_or_none() if existe is not None: if force: existe.type_origine = ab["type_absence"] existe.statut = "excusee" if ab["type_absence"] == "E" else "a_traiter" existe.updated_by = None if ep_pending: session.delete(ep_pending) nb_mises_a_jour += 1 entry = _upd_by_ap.setdefault(apprenti.id, { "nom": apprenti.nom, "prenom": apprenti.prenom, "dates": [] }) try: entry["dates"].append(f"{ab['date'].strftime('%d.%m')} {ab['periode']}") except Exception: entry["dates"].append(str(ab["date"])) elif ep_pending: # Modification en attente de sync vers Escada → ne pas écraser nb_doublons += 1 nb_pending_skipped += 1 elif existe.statut == "publiee_escada": # Vient d'être poussée vers Escada. Si le PDF confirme la # valeur (type identique), on transitionne vers le statut # "stable" pour que la prochaine modif locale crée bien un # pending. Si le PDF ne confirme pas encore (stale), on # préserve le marqueur en attendant un PDF rafraîchi. if existe.type_origine == ab["type_absence"]: existe.statut = ( "excusee" if ab["type_absence"] == "E" else "a_traiter" ) nb_doublons += 1 else: nb_doublons += 1 nb_pending_skipped += 1 elif existe.type_origine != ab["type_absence"]: existe.type_origine = ab["type_absence"] existe.statut = "excusee" if ab["type_absence"] == "E" else "a_traiter" nb_mises_a_jour += 1 entry = _upd_by_ap.setdefault(apprenti.id, { "nom": apprenti.nom, "prenom": apprenti.prenom, "dates": [] }) try: entry["dates"].append(f"{ab['date'].strftime('%d.%m')} {ab['periode']}") except Exception: entry["dates"].append(str(ab["date"])) else: nb_doublons += 1 else: # Cas: l'absence est dans le PDF Escada mais pas en DB. # Si un pending existe (typiquement action="clear" = suppression # locale en attente de push), respecter le pending sauf en force. if ep_pending and not force: nb_pending_skipped += 1 continue if ep_pending and force: # Force : on écrase le pending et on (ré)insère l'absence session.delete(ep_pending) statut_initial = "excusee" if ab["type_absence"] == "E" else "a_traiter" session.add( Absence( apprenti_id=apprenti.id, date=ab["date"], periode=ab["periode"], type_origine=ab["type_absence"], statut=statut_initial, import_id=import_rec.id, ) ) nb_nouvelles += 1 entry = _nouv_by_ap.setdefault(apprenti.id, { "nom": apprenti.nom, "prenom": apprenti.prenom, "dates": [] }) try: entry["dates"].append(f"{ab['date'].strftime('%d.%m')} {ab['periode']}") except Exception: entry["dates"].append(str(ab["date"])) # ── Diff : absences supprimées dans Escada (orphelines) ────────────────── # On veut toutes les abs des apprentis de cette classe dans la fenêtre de # dates couverte par le PDF — y compris les abs créées localement # (import_id=None) qui ne joignent pas Import. pdf_dates: set = set() for a_data in apprentis_data: for ab in a_data["absences"]: pdf_dates.add(ab["date"]) if pdf_dates: date_min = min(pdf_dates) date_max = max(pdf_dates) prev_absences = session.execute( select(Absence) .join(Apprenti, Apprenti.id == Absence.apprenti_id) .where( Apprenti.classe == classe, Absence.date >= date_min, Absence.date <= date_max, ) ).scalars().all() else: prev_absences = [] details_orphelines: list[str] = [] nb_pendings_orphelins = 0 # Index des apprentis pour récupérer leur nom/prénom sans re-query par orphelin apprenti_by_id: dict[int, Apprenti] = {} for ab in prev_absences: if (ab.apprenti_id, ab.date, ab.periode) not in seen_keys: ep = session.execute( select(EscadaPending).where( EscadaPending.apprenti_id == ab.apprenti_id, EscadaPending.date == ab.date, EscadaPending.periode == ab.periode, ) ).scalar_one_or_none() # Orphelin avec pending : préserve l'intent local (sauf force) # — typique : utilisateur a ajouté localement (action="E") et n'a pas # encore pushé vers Escada. Supprimer écraserait son travail. if ep and not force: nb_pending_skipped += 1 continue # Orphelin déjà poussé sur Escada (statut="publiee_escada") : # Escada peut servir un PDF stale ne reflétant pas encore notre # push. Préserver l'absence pour éviter de la supprimer juste # après l'avoir poussée. Un sync ultérieur avec PDF rafraîchi # ramènera l'absence à "excusee"/"a_traiter". if ab.statut == "publiee_escada" and not force: nb_pending_skipped += 1 continue if ep: session.delete(ep) nb_pendings_orphelins += 1 ap = apprenti_by_id.get(ab.apprenti_id) if ap is None: ap = session.get(Apprenti, ab.apprenti_id) if ap is not None: apprenti_by_id[ab.apprenti_id] = ap try: date_str = ab.date.strftime("%d.%m.%Y") except Exception: date_str = str(ab.date) nom = f"{ap.prenom} {ap.nom}" if ap else f"apprenti#{ab.apprenti_id}" details_orphelines.append(f"{nom} — {date_str} P{ab.periode}") session.delete(ab) nb_supprimees += 1 import_rec.nb_absences_nouvelles = nb_nouvelles import_rec.nb_absences_doublons = nb_doublons session.commit() def _fmt(d: dict) -> str: dates = d["dates"] sample = ", ".join(dates[:5]) extra = f" +{len(dates) - 5}" if len(dates) > 5 else "" return f"{d['prenom']} {d['nom']} — {len(dates)} abs. ({sample}{extra})" return ImportResult( import_id=import_rec.id, classe=classe, semestre=semestre, fichier=pdf_path.name, nb_apprentis=len(apprentis_data), nb_absences_nouvelles=nb_nouvelles, nb_absences_doublons=nb_doublons, nb_absences_mises_a_jour=nb_mises_a_jour, nb_absences_supprimees=nb_supprimees, nb_pendings_orphelins_supprimes=nb_pendings_orphelins, nb_absences_pending_skipped=nb_pending_skipped, details_nouvelles=[_fmt(d) for d in _nouv_by_ap.values()], details_mises_a_jour=[_fmt(d) for d in _upd_by_ap.values()], details_orphelines=details_orphelines, )