""" Motore di matching a cascata per il modulo Linker. Replica la logica VBA di Linker.xlsm. """ import logging import pandas as pd from typing import Optional, Dict logger = logging.getLogger(__name__) # Articoli da spostare in fondo durante la normalizzazione ARTICOLI_IT = ['GLI ', 'LE ', 'LO ', 'LA ', 'IL ', 'I ', "L'"] ARTICOLI_EN = ['THE ', 'AN ', 'A '] # Colori aggancio COLOR_REPOSITORY = '#ADD8E6' # Azzurro COLOR_ONAIR = '#FFFF99' # Giallo (codice trovato, non in REPOSITORY) COLOR_IMDB = '#FFFF99' # Giallo COLOR_DERIVED = '#FFFF99' # Giallo (IMDB + RIFER derivato) COLOR_NOT_FOUND = '#FF9999' # Rosso # Nomi alternativi per la colonna VEG FREE in CUSTOM_ANAGR_ORIZ VEG_COL_CANDIDATES = ['VEG_FREE', 'VEG FREE', 'VEGFREE', 'VEG-FREE', 'VEG_FREE_', 'VEG'] def normalizza(titolo: str) -> str: """ Sposta l'articolo iniziale (IT o EN) in fondo al titolo. Esempio: "IL GLADIATORE" → "GLADIATORE, IL" "THE GODFATHER" → "GODFATHER, THE" """ if not titolo or not str(titolo).strip(): return '' t = str(titolo).strip().upper() # Prova prima articoli più lunghi (es. "GLI" prima di "I") for art in ARTICOLI_IT + ARTICOLI_EN: if t.startswith(art): body = t[len(art):].strip() return f"{body}, {art.strip()}" return t class LinkerEngine: """ Esegue la cascata di matching su tutte le righe della griglia. Carica le tabelle DB in memoria una volta sola all'avvio del matching. """ def __init__(self, linker_db, fornitore: Optional[str] = None, is_seriale: bool = True): self.linker_db = linker_db self.fornitore = fornitore self.is_seriale = is_seriale # DataFrames caricati da load_tables() self.df_custom: pd.DataFrame = pd.DataFrame() self.df_imdb: pd.DataFrame = pd.DataFrame() self.df_repo: pd.DataFrame = pd.DataFrame() # Nomi effettivi delle colonne (rilevati al caricamento) self._veg_col: Optional[str] = None def load_tables(self): """ Carica CUSTOM_ANAGR_ORIZ, IMDB e (se listino) LINKER_REPOSITORY in memoria e pre-calcola le forme normalizzate dei titoli. """ logger.info("Caricamento tabelle in memoria...") # CUSTOM_ANAGR_ORIZ self.df_custom = self.linker_db.get_all_custom() if not self.df_custom.empty: self.df_custom['_TO_NORM'] = self.df_custom.get('TO', pd.Series(dtype=str)).apply( lambda x: normalizza(str(x)) if pd.notna(x) and str(x).strip() else '' ) self.df_custom['_TI_NORM'] = self.df_custom.get('TI', pd.Series(dtype=str)).apply( lambda x: normalizza(str(x)) if pd.notna(x) and str(x).strip() else '' ) # Colonna RIFER normalizzata a stringa (Access restituisce float come 20345.0) self.df_custom['_RIFER_NORM'] = self.df_custom.get('RIFER', pd.Series(dtype=str)).apply( self._safe_str ) # Colonna IMDB_CODICE normalizzata self.df_custom['_IMDB_CODE_NORM'] = self.df_custom.get('IMDB_CODICE', pd.Series(dtype=str)).apply( lambda x: str(x).strip() if pd.notna(x) and str(x).strip() else '' ) # Rileva colonna VEG FREE for col in VEG_COL_CANDIDATES: if col in self.df_custom.columns: self._veg_col = col break logger.info(f"CUSTOM caricata: {len(self.df_custom)} righe, VEG col: {self._veg_col}") # IMDB self.df_imdb = self.linker_db.get_all_imdb() if not self.df_imdb.empty: self.df_imdb['_TO_NORM'] = self.df_imdb.get('TO', pd.Series(dtype=str)).apply( lambda x: normalizza(str(x)) if pd.notna(x) and str(x).strip() else '' ) logger.info(f"IMDB caricata: {len(self.df_imdb)} righe") # LINKER_REPOSITORY (solo in modalità listino) if self.fornitore: self.df_repo = self.linker_db.get_repository_by_fornitore(self.fornitore) if not self.df_repo.empty: self.df_repo['_TITOLO_NORM'] = self.df_repo.get('TITOLO', pd.Series(dtype=str)).apply( lambda x: normalizza(str(x)) if pd.notna(x) and str(x).strip() else '' ) logger.info(f"LINKER_REPOSITORY caricato: {len(self.df_repo)} righe per '{self.fornitore}'") else: self.df_repo = pd.DataFrame() def match_row(self, to_input: str, ti_input: str, anno_input: str, regista_input: str) -> Dict: """ Esegue la cascata di matching per una riga. Restituisce un dict con match_source, color e tutti i campi output. """ to_norm = normalizza(to_input) if to_input and to_input.strip() else '' ti_norm = normalizza(ti_input) if ti_input and ti_input.strip() else '' # Step 1 — LINKER_REPOSITORY (solo modalità listino) if self.fornitore and (to_norm or ti_norm): result = self._match_step1(to_norm, ti_norm, anno_input) if result: return result # Step 2 — CUSTOM_ANAGR_ORIZ if to_norm or ti_norm: result = self._match_step2(to_norm, ti_norm) if result: if self._anno_too_far(anno_input, result.get('ANNO', '')): return {'match_source': 'NOT_FOUND', 'color': COLOR_NOT_FOUND} return result # Step 3 — IMDB (solo se REGISTA presente) if regista_input and regista_input.strip() and (to_norm or ti_norm): result = self._match_step3(to_norm, anno_input, regista_input) if result: if self._anno_too_far(anno_input, result.get('ANNO', '')): return {'match_source': 'NOT_FOUND', 'color': COLOR_NOT_FOUND} return result # Step 4 — NOT_FOUND return {'match_source': 'NOT_FOUND', 'color': COLOR_NOT_FOUND} # ------------------------------------------------------------------ # # Step 1: LINKER_REPOSITORY # # ------------------------------------------------------------------ # def _match_step1(self, to_norm: str, ti_norm: str, anno_input: str) -> Optional[Dict]: if self.df_repo.empty: return None # Match per titolo normalizzato mask = pd.Series(False, index=self.df_repo.index) if to_norm: mask |= (self.df_repo['_TITOLO_NORM'] == to_norm) if ti_norm: mask |= (self.df_repo['_TITOLO_NORM'] == ti_norm) candidates = self.df_repo[mask] if candidates.empty: return None # Tiebreak per ANNO più vicino, poi per RIFER valorizzato if len(candidates) > 1: candidates = self._tiebreak_by_anno(candidates, anno_input) if candidates is None or len(candidates) > 1: # Ultimo tentativo: preferisce il record con RIFER valorizzato if candidates is not None: with_rifer = candidates[ candidates.get('RIFER', pd.Series(dtype=str)) .apply(lambda x: bool(self._safe_str(x))) ] if len(with_rifer) == 1: candidates = with_rifer logger.info("Tiebreak REPOSITORY risolto per RIFER valorizzato") if candidates is None or len(candidates) > 1: logger.debug("Match multiplo irrisolvibile in REPOSITORY → NOT_FOUND") return None repo_row = candidates.iloc[0] rifer = self._safe_str(repo_row.get('RIFER')) imdb_code = self._safe_str(repo_row.get('COD_IMDB')) # Dati da CUSTOM_ANAGR_ORIZ (prevale se RIFER disponibile) if rifer: custom_match = self.df_custom[self.df_custom['_RIFER_NORM'] == rifer] \ if '_RIFER_NORM' in self.df_custom.columns else pd.DataFrame() if not custom_match.empty: result = self._build_from_custom(custom_match.iloc[0]) result['match_source'] = 'REPOSITORY' result['color'] = COLOR_REPOSITORY if imdb_code: result['IMDB_CODICE'] = imdb_code return result # RIFER vuoto ma COD_IMDB presente. # Se prodotti singoli (scelto dall'utente): deriva RIFER da CUSTOM via IMDB_CODICE. # Se prodotti seriali: usa IMDB solo per i metadati (RIFER resta vuoto). if imdb_code: if not self.is_seriale and '_IMDB_CODE_NORM' in self.df_custom.columns: custom_via_imdb = self.df_custom[ self.df_custom['_IMDB_CODE_NORM'] == imdb_code ] if not custom_via_imdb.empty: result = self._build_from_custom(custom_via_imdb.iloc[0]) result['match_source'] = 'REPOSITORY' result['color'] = COLOR_REPOSITORY result['IMDB_CODICE'] = imdb_code result['rifer_derived'] = True return result imdb_match = self.df_imdb[ self.df_imdb.get('IMDB_CODICE', pd.Series(dtype=str)) == imdb_code ] if not imdb_match.empty: result = self._build_from_imdb(imdb_match.iloc[0]) result['match_source'] = 'REPOSITORY' result['color'] = COLOR_REPOSITORY return result # Record trovato ma senza dati collegati return { 'match_source': 'REPOSITORY', 'color': COLOR_REPOSITORY, 'RIFER': rifer, 'IMDB_CODICE': imdb_code, 'TI': '', 'TO': '', 'ANNO': '', 'REGISTA': '', 'TIPOL': '', 'PAESE': '', 'GENERE': '', 'EPIS': '', 'DURATA': '', 'SUPERSERIE': '', 'VEG_FREE': '', 'ATTORI': '', } # ------------------------------------------------------------------ # # Step 2: CUSTOM_ANAGR_ORIZ # # ------------------------------------------------------------------ # def _match_step2(self, to_norm: str, ti_norm: str) -> Optional[Dict]: if self.df_custom.empty: return None match = pd.DataFrame() if to_norm and '_TO_NORM' in self.df_custom.columns: match = self.df_custom[self.df_custom['_TO_NORM'] == to_norm] if match.empty and ti_norm and '_TI_NORM' in self.df_custom.columns: match = self.df_custom[self.df_custom['_TI_NORM'] == ti_norm] if match.empty: return None result = self._build_from_custom(match.iloc[0]) result['match_source'] = 'ONAIR' result['color'] = COLOR_ONAIR if self.is_seriale and len(match) > 1: result['RIFER'] = '' # N stagioni OnAir con stesso titolo — RIFER ambiguo return result # ------------------------------------------------------------------ # # Step 3: IMDB # # ------------------------------------------------------------------ # def _match_step3(self, to_norm: str, anno_input: str, regista_input: str) -> Optional[Dict]: if self.df_imdb.empty: return None # Candidati per titolo normalizzato candidates = pd.DataFrame() if to_norm and '_TO_NORM' in self.df_imdb.columns: candidates = self.df_imdb[self.df_imdb['_TO_NORM'] == to_norm] if candidates.empty: return None # Filtra per REGISTA (cognome) best = self._filter_by_regista_or_anno(candidates, anno_input, regista_input) if best is None: return None imdb_code = self._safe_str(best.get('IMDB_CODICE')) # Lookup RIFER da CUSTOM_ANAGR_ORIZ tramite IMDB_CODICE rifer = '' custom_extra = pd.DataFrame() if imdb_code and '_IMDB_CODE_NORM' in self.df_custom.columns: custom_extra = self.df_custom[self.df_custom['_IMDB_CODE_NORM'] == imdb_code] if not custom_extra.empty: if self.is_seriale and len(custom_extra) > 1: rifer = '' # N stagioni con stesso IMDB — RIFER ambiguo else: rifer = self._safe_str(custom_extra.iloc[0].get('RIFER')) if not custom_extra.empty: # Trovato in CUSTOM (via IMDB_CODICE o titolo): usa dati completi result = self._build_from_custom(custom_extra.iloc[0]) if self.is_seriale and len(custom_extra) > 1: result['RIFER'] = '' # N stagioni con stesso IMDB — RIFER ambiguo result['match_source'] = 'IMDB' result['color'] = COLOR_DERIVED if rifer else COLOR_IMDB if imdb_code: result['IMDB_CODICE'] = imdb_code # mantieni il codice IMDB trovato else: # Nessun record in CUSTOM: usa solo dati IMDB result = self._build_from_imdb(best) result['match_source'] = 'IMDB' result['color'] = COLOR_IMDB return result # ------------------------------------------------------------------ # # Helper: costruttori risultato # # ------------------------------------------------------------------ # def _build_from_custom(self, row) -> Dict: reg_cogn = self._safe_str(row.get('REGISTA_COGN')) reg_nome = self._safe_str(row.get('REGISTA_NOME')) regista = (reg_nome + ' ' + reg_cogn).strip() return { 'RIFER': self._safe_str(row.get('RIFER')), 'IMDB_CODICE': self._safe_str(row.get('IMDB_CODICE')), 'TI': self._safe_str(row.get('TI')), 'TO': self._safe_str(row.get('TO')), 'ANNO': self._format_anno(row.get('ANNO')), 'REGISTA': regista, 'TIPOL': self._safe_str(row.get('TIPOL')), 'PAESE': self._safe_str(row.get('PAESE')), 'GENERE': self._safe_str(row.get('GENERE1')), 'EPIS': self._safe_str(row.get('EPIS')), 'DURATA': self._safe_str(row.get('DUR')), 'SUPERSERIE': self._safe_str(row.get('SUPERSERIE')), 'VEG_FREE': self._get_veg(row), 'ATTORI': self._get_attori(row), } def _build_from_imdb(self, row) -> Dict: return { 'RIFER': '', 'IMDB_CODICE': self._safe_str(row.get('IMDB_CODICE')), 'TI': self._safe_str(row.get('TI')), 'TO': self._safe_str(row.get('TO')), 'ANNO': self._format_anno(row.get('ANNO')), 'REGISTA': self._safe_str(row.get('REGISTA')), 'TIPOL': self._safe_str(row.get('TIPOL')), 'PAESE': self._safe_str(row.get('PAESE')), 'GENERE': self._safe_str(row.get('GENERE')), 'EPIS': self._safe_str(row.get('EPIS')), 'DURATA': self._safe_str(row.get('DURATA')), 'SUPERSERIE': '', 'VEG_FREE': '', 'ATTORI': '', } # ------------------------------------------------------------------ # # Helper: utilità # # ------------------------------------------------------------------ # def _tiebreak_by_anno(self, candidates: pd.DataFrame, anno_input: str) -> Optional[pd.DataFrame]: """Ordina candidati per anno più vicino all'input. Ritorna None se ancora ambiguo.""" if not anno_input: return None try: anno_int = int(anno_input) except (ValueError, TypeError): return None df = candidates.copy() df['_ANNO_DIFF'] = df.get('ANNO', pd.Series(dtype=object)).apply( lambda x: abs(int(float(str(x))) - anno_int) if pd.notna(x) and str(x).replace('.', '').isdigit() else 9999 ) df = df.sort_values('_ANNO_DIFF') # Se i due migliori hanno la stessa distanza → ambiguo if len(df) >= 2 and df.iloc[0]['_ANNO_DIFF'] == df.iloc[1]['_ANNO_DIFF']: return None return df.head(1) def _filter_by_regista_or_anno(self, candidates: pd.DataFrame, anno_input: str, regista_input: str) -> Optional[object]: """ Filtra i candidati IMDB per REGISTA (cognome) o per ANNO ±1. Restituisce la prima riga valida o None. """ # Estrai ultima parola come probabile cognome words = regista_input.strip().upper().split() cognome = words[-1] if words else '' if cognome and 'REGISTA' in candidates.columns: reg_col = candidates['REGISTA'].fillna('').str.upper() reg_matches = candidates[reg_col.str.contains(cognome, na=False, regex=False)] if not reg_matches.empty: return reg_matches.iloc[0] # Fallback: match per anno ±1 if anno_input and 'ANNO' in candidates.columns: try: anno_int = int(anno_input) anno_col = candidates['ANNO'].apply( lambda x: int(float(str(x))) if pd.notna(x) and str(x).replace('.', '').isdigit() else None ) anno_matches = candidates[ anno_col.apply(lambda a: a is not None and abs(a - anno_int) <= 1) ] if not anno_matches.empty: return anno_matches.iloc[0] except (ValueError, TypeError): pass return None def _get_attori(self, row) -> str: """Concatena tutte le colonne ATTORE* presenti nella riga, ordinate per nome.""" cols = sorted(c for c in row.index if str(c).upper().startswith('ATTORE')) return ', '.join(self._safe_str(row[c]) for c in cols if self._safe_str(row[c])) def _safe_str(self, val) -> str: if val is None: return '' try: if pd.isna(val): return '' except TypeError: pass if isinstance(val, float): # Float che è in realtà un intero (es. 20345.0 → "20345") if val == int(val): return str(int(val)) return str(val).strip() def _format_anno(self, val) -> str: if val is None: return '' try: if pd.isna(val): return '' except TypeError: pass try: return str(int(float(str(val)))) except (ValueError, TypeError): try: return str(val).strip() except Exception: return '' def _anno_too_far(self, anno_input: str, anno_match: str) -> bool: """ Ritorna True se la differenza di anni supera 2. Se uno dei due valori manca o non è parsabile → False (beneficio del dubbio). """ if not anno_input or not anno_match: return False try: return abs(int(anno_input) - int(anno_match)) > 2 except (ValueError, TypeError): return False def _get_veg(self, row) -> str: """Legge VEG FREE dalla riga usando il nome colonna rilevato.""" if self._veg_col and self._veg_col in (row.index if hasattr(row, 'index') else []): return self._safe_str(row.get(self._veg_col)) # Tentativo su tutti i candidati for col in VEG_COL_CANDIDATES: val = row.get(col) if val is not None: return self._safe_str(val) return ''