# ============================================ # Projet : API-KA # Fichier : src/api/ecopdf.py # Node : m3u96b # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Date : 2026-08-19 # ============================================ """Rapport écosystème Groupe KA — un seul PDF consolidant toutes les plateformes. Compose le moteur PDF commun v2 (``src/api/kapdf.py``) sans le dupliquer : ``EcosystemReport`` étend ``GroupeKAReport`` et réutilise ses primitives (cartes KPI, jauges, séries, répartitions, tableaux, records, page de fin). Il ajoute la couverture « écosystème », la page de consolidation (totaux + classement) puis une section par plateforme, dont le contenu suit le ``mode`` demandé (contrat SPEC v2, 5 rapports) : - ``complet`` : KPI + jauges + records de chaque plateforme ; - ``synthese`` : couverture + consolidation + page de fin seulement ; - ``tendances`` : KPI + séries temporelles (avec stats de séries) ; - ``repartitions`` : répartitions, distributions, géo, activité horaire ; - ``donnees`` : tableaux détaillés de chaque plateforme. Un mode inconnu retombe sur ``complet`` (normalisé par le moteur v2). Une plateforme injoignable devient une section « données indisponibles » propre — jamais un échec global. """ from __future__ import annotations from typing import Any from src.api.kapdf import ( GREEN, INK, INK2, INK3, PAPER, REPORT_MODES, SURFACE2, GroupeKAReport, _fr, _hex, ) # Identifiants de KPI « nouveautés de la période » connus des plateformes # (chacune nomme la sienne : nouvelles annonces, nouveaux véhicules, ajouts…). NEW_KPI_IDS = ( "nouvelles", "nouveaux", "nouveautes", "new", "ajouts", "added", "indexees", "releves", "records", ) ECO_SITE = { "wordmark": "Écosystème", "accent": "#d9f26b", "domain": "www.groupe-ka.com", "tagline": "Le portail de l'écosystème ·Ka", } def main_kpi(dash: dict | None) -> dict | None: """Premier KPI numérique = volume principal de la plateforme (convention du contrat SPEC : l'indicateur de volume est listé en premier).""" for k in (dash or {}).get("kpis") or []: if isinstance(k.get("value"), (int, float)) and not isinstance(k["value"], bool): return k return None def new_kpi(dash: dict | None) -> dict | None: """KPI « nouveautés de la période » si la plateforme le mesure.""" kpis = (dash or {}).get("kpis") or [] for kid in NEW_KPI_IDS: for k in kpis: if k.get("id") == kid and isinstance(k.get("value"), (int, float)): return k return None class EcosystemReport(GroupeKAReport): """PDF consolidé : couverture Groupe KA, consolidation, 1 section par plateforme (contenu selon le mode v2), page de fin. ``platforms`` : liste de ``{"site": …, "dashboard": dict | None, "error": str | None}`` (une entrée par plateforme de données).""" def __init__(self, platforms: list[dict], period: dict, mode: str = "complet"): super().__init__(dict(ECO_SITE), {"period": period or {}}, mode) self.platforms = platforms self._kpi_title: str | None = None available = [p for p in platforms if p.get("dashboard")] missing = [p for p in platforms if not p.get("dashboard")] def _vol(p: dict) -> float: k = main_kpi(p.get("dashboard")) return float(k["value"]) if k else 0.0 self.ranked = sorted(available, key=_vol, reverse=True) + missing # Le moteur v2 titre toujours le bloc KPI « Synthèse des indicateurs » ; # dans le rapport écosystème chaque bloc doit porter son propre titre. def _section_title(self, title): if self._kpi_title and title == "Synthèse des indicateurs": title = self._kpi_title super()._section_title(title) def _kpis_titled(self, title: str): self._kpi_title = title try: self._kpis() finally: self._kpi_title = None # ------------------------------------------------------------- couverture def _cover(self): p = self.pdf p.cover_mode = True p.set_auto_page_break(False) p.add_page() p.set_fill_color(*PAPER) p.rect(0, 0, 210, 297, style="F") p.set_draw_color(*INK) p.set_line_width(1.0) p.rect(10, 10, 190, 277) p.set_font("helvetica", "B", 10) p.set_text_color(*GREEN) p.set_xy(24, 34) p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE") # wordmark « Groupe [KA] » : boîte encre, lettres lime p.set_xy(24, 70) p.set_font("helvetica", "B", 40) p.set_text_color(*INK) p.cell(p.get_string_width("Groupe") + 2, 20, "Groupe") bw = p.get_string_width("KA") + 12 x = p.get_x() + 2 p.set_fill_color(*INK) p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3) p.set_text_color(*self.accent) p.set_xy(x + 6, 70) p.cell(bw - 12, 18, "KA") p.set_xy(24, 100) p.set_font("helvetica", "B", 16) p.set_text_color(*INK) p.cell(0, 8, f"RAPPORT ÉCOSYSTÈME — {len(self.platforms)} plateformes") p.set_xy(24, 109) p.set_font("helvetica", "", 11) p.set_text_color(*INK2) p.cell(0, 6, "Consolidation des plateformes de données du Groupe KA") from datetime import datetime from zoneinfo import ZoneInfo now = datetime.now(ZoneInfo("America/Toronto")) per = self.d.get("period", {}) or {} joined = sum(1 for pl in self.platforms if pl.get("dashboard")) rows = [ ( "Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else ""), ), ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), ( "Plateformes", f"{len(self.platforms)} plateformes de données + le hub groupe-ka.com " f"({joined}/{len(self.platforms)} jointes)", ), ("Type de rapport", REPORT_MODES[self.mode]), ] y = 128 p.set_font("helvetica", "", 10.5) for k, v in rows: p.set_xy(24, y) p.set_text_color(*INK3) p.cell(40, 6, k) p.set_text_color(*INK) p.set_font("helvetica", "B", 10.5) p.cell(0, 6, str(v)) p.set_font("helvetica", "", 10.5) y += 8 # bande encre au pied p.set_fill_color(*INK) p.rect(10, 262, 190, 25, style="F") p.set_xy(24, 270) p.set_font("helvetica", "B", 12) p.set_text_color(255, 255, 255) p.cell(60, 8, "par Groupe ") p.set_text_color(*self.accent) p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270) p.cell(20, 8, "KA") p.set_font("helvetica", "B", 10) p.set_xy(24, 270) p.set_text_color(*self.accent) p.cell(162, 8, "groupe-ka.com", align="R") p.set_auto_page_break(True, margin=22) p.cover_mode = False # ---------------------------------------------------------- consolidation def _consolidation(self): p = self.pdf available = [pl for pl in self.ranked if pl.get("dashboard")] missing = [pl for pl in self.ranked if not pl.get("dashboard")] total_vol = 0.0 total_new = 0.0 has_new = 0 for pl in available: mk = main_kpi(pl["dashboard"]) if mk: total_vol += float(mk["value"]) nk = new_kpi(pl["dashboard"]) if nk: total_new += float(nk["value"]) has_new += 1 kpis: list[dict[str, Any]] = [ { "id": "vol", "label": "Volume principal agrégé (somme des indicateurs principaux)", "value": int(total_vol), }, { "id": "joined", "label": "Plateformes de données jointes au moment de la génération", "value": len(available), "unit": f"/ {len(self.platforms)}", }, ] if has_new: kpis.append( { "id": "new", "label": f"Nouveautés sur la période ({has_new} plateformes qui les mesurent)", "value": int(total_new), } ) saved = self.d self.d = {"kpis": kpis} self._kpis_titled("Consolidation de l'écosystème") self.d = saved rows: list[list[Any]] = [] for i, pl in enumerate(self.ranked, 1): s = pl["site"] dash = pl.get("dashboard") if dash: mk = main_kpi(dash) nk = new_kpi(dash) rows.append( [ f"{i}. {s.get('wordmark', s.get('id', ''))}", str(mk.get("label", ""))[:34] if mk else "—", mk["value"] if mk else "—", nk["value"] if nk else "—", ] ) else: rows.append( [ f"{i}. {s.get('wordmark', s.get('id', ''))}", "données indisponibles", "—", "—", ] ) self._table( { "id": "classement", "title": "Classement des plateformes (volume principal)", "columns": [ "Plateforme", "Indicateur principal", "Volume", "Nouveautés (période)", ], "rows": rows, } ) p.set_font("helvetica", "", 7.6) p.set_text_color(*INK3) note = ( "Le « volume principal » est le premier indicateur du tableau de bord de " "chaque plateforme (annonces actives, véhicules, produits, pages indexées…). " "Les volumes ne sont pas de même nature : la somme donne un ordre de " "grandeur de l'écosystème, pas une métrique homogène. Données lues en " "direct sur chaque plateforme (cache 10 minutes)." ) if missing: names = ", ".join(pl["site"].get("wordmark", "") for pl in missing) note += f" Plateformes injoignables au moment de la génération : {names}." p.multi_cell(174, 4.2, note) p.ln(4) # ------------------------------------------------- section par plateforme def _platform_header(self, site: dict, accent: tuple): """Titre de section : pastille accent + wordmark (boîte encre) + domaine.""" p = self.pdf y = p.get_y() + 2 p.set_fill_color(*accent) p.set_draw_color(*INK) p.set_line_width(0.45) p.rect(p.l_margin, y + 2, 6, 6, style="DF", round_corners=True, corner_radius=1.5) wm = site.get("wordmark", site.get("id", "")) left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None) p.set_xy(p.l_margin + 9, y - 1) p.set_font("helvetica", "B", 19) p.set_text_color(*INK) p.cell(p.get_string_width(left) + 1.5, 11, left) if boxed: bw = p.get_string_width(boxed) + 7 bx = p.get_x() + 1 p.set_fill_color(*INK) p.rect(bx, y - 0.6, bw, 10.4, style="F", round_corners=True, corner_radius=2) p.set_text_color(*accent) p.set_xy(bx + 3.5, y - 1) p.cell(bw - 7, 11, boxed) p.set_font("helvetica", "B", 9) p.set_text_color(*INK3) p.set_xy(p.l_margin, y + 2) p.cell(174, 6, "https://" + site.get("domain", ""), align="R") p.set_y(y + 12) if site.get("tagline"): p.set_x(p.l_margin) p.set_font("helvetica", "", 9) p.set_text_color(*INK2) p.cell(0, 5, str(site["tagline"])) p.ln(7) def _platform_section(self, pl: dict): p = self.pdf site = pl["site"] dash = pl.get("dashboard") accent = _hex(site.get("accent", "#d9f26b")) p.add_page() self._platform_header(site, accent) if not dash: y = p.get_y() + 2 self._card(p.l_margin, y, 174, 26, fill=SURFACE2) p.set_xy(p.l_margin + 6, y + 5) p.set_font("helvetica", "B", 11) p.set_text_color(*INK2) p.cell(0, 6, "Données indisponibles") p.set_xy(p.l_margin + 6, y + 12) p.set_font("helvetica", "", 8.6) p.set_text_color(*INK3) p.multi_cell( 162, 4.4, f"La plateforme n'a pas répondu au moment de la génération " f"({pl.get('error') or 'erreur inconnue'}). Ses statistiques restent " f"consultables sur https://{site.get('domain', '')}/stats.", ) p.set_y(y + 30) return # fraîcheur de la plateforme upd = dash.get("updated") if upd: p.set_x(p.l_margin) p.set_font("helvetica", "", 7.6) p.set_text_color(*INK3) p.cell(0, 4, f"Mis à jour : {str(upd)[:19].replace('T', ' à ')}") p.ln(6) # réutilise le moteur v2 avec l'identité et le dashboard de la plateforme saved = (self.d, self.site, self.accent) self.d, self.site, self.accent = dash, site, accent wm = site.get("wordmark", "") try: if self.mode == "tendances": self._kpis_titled("Indicateurs — " + wm) if dash.get("series") or dash.get("multiseries") or dash.get("stacked"): self._section_title("Évolution & tendances — " + wm) self._all_series(with_stats=True) elif self.mode == "repartitions": if ( dash.get("breakdowns") or dash.get("distributions") or dash.get("geo") or dash.get("hourly") ): self._section_title("Répartitions & distributions — " + wm) self._all_breakdowns() else: self._kpis_titled("Indicateurs — " + wm) elif self.mode == "donnees": tables = dash.get("tables") or [] if tables: for t in tables: self._table(t, max_rows=200) else: self._kpis_titled("Indicateurs — " + wm) else: # complet self._kpis_titled("Indicateurs — " + wm) self._gauges() self._records() finally: self.d, self.site, self.accent = saved # ------------------------------------------------------------------ build def build(self) -> bytes: p = self.pdf p.alias_nb_pages() self._cover() p.add_page() self._consolidation() if self.mode != "synthese": for pl in self.ranked: self._platform_section(pl) self._final_page() return bytes(p.output())