API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
1# ============================================2# Projet : API-KA3# Fichier : src/api/ecopdf.py4# Node : m3u96b5# Author : Simon-Pierre Boucher6# Contact : contact@spboucher.ai7# Date : 2026-08-198# ============================================9"""Rapport écosystème Groupe KA — un seul PDF consolidant toutes les plateformes.1011Compose le moteur PDF commun v2 (``src/api/kapdf.py``) sans le dupliquer :12``EcosystemReport`` étend ``GroupeKAReport`` et réutilise ses primitives13(cartes KPI, jauges, séries, répartitions, tableaux, records, page de fin).14Il ajoute la couverture « écosystème », la page de consolidation (totaux +15classement) puis une section par plateforme, dont le contenu suit le ``mode``16demandé (contrat SPEC v2, 5 rapports) :1718- ``complet`` : KPI + jauges + records de chaque plateforme ;19- ``synthese`` : couverture + consolidation + page de fin seulement ;20- ``tendances`` : KPI + séries temporelles (avec stats de séries) ;21- ``repartitions`` : répartitions, distributions, géo, activité horaire ;22- ``donnees`` : tableaux détaillés de chaque plateforme.2324Un mode inconnu retombe sur ``complet`` (normalisé par le moteur v2). Une25plateforme injoignable devient une section « données indisponibles » propre —26jamais un échec global.27"""2829from __future__ import annotations3031from typing import Any3233from src.api.kapdf import (34 GREEN,35 INK,36 INK2,37 INK3,38 PAPER,39 REPORT_MODES,40 SURFACE2,41 GroupeKAReport,42 _fr,43 _hex,44)4546# Identifiants de KPI « nouveautés de la période » connus des plateformes47# (chacune nomme la sienne : nouvelles annonces, nouveaux véhicules, ajouts…).48NEW_KPI_IDS = (49 "nouvelles",50 "nouveaux",51 "nouveautes",52 "new",53 "ajouts",54 "added",55 "indexees",56 "releves",57 "records",58)5960ECO_SITE = {61 "wordmark": "Écosystème",62 "accent": "#d9f26b",63 "domain": "www.groupe-ka.com",64 "tagline": "Le portail de l'écosystème ·Ka",65}666768def main_kpi(dash: dict | None) -> dict | None:69 """Premier KPI numérique = volume principal de la plateforme (convention70 du contrat SPEC : l'indicateur de volume est listé en premier)."""71 for k in (dash or {}).get("kpis") or []:72 if isinstance(k.get("value"), (int, float)) and not isinstance(k["value"], bool):73 return k74 return None757677def new_kpi(dash: dict | None) -> dict | None:78 """KPI « nouveautés de la période » si la plateforme le mesure."""79 kpis = (dash or {}).get("kpis") or []80 for kid in NEW_KPI_IDS:81 for k in kpis:82 if k.get("id") == kid and isinstance(k.get("value"), (int, float)):83 return k84 return None858687class EcosystemReport(GroupeKAReport):88 """PDF consolidé : couverture Groupe KA, consolidation, 1 section par89 plateforme (contenu selon le mode v2), page de fin.9091 ``platforms`` : liste de ``{"site": …, "dashboard": dict | None,92 "error": str | None}`` (une entrée par plateforme de données)."""9394 def __init__(self, platforms: list[dict], period: dict, mode: str = "complet"):95 super().__init__(dict(ECO_SITE), {"period": period or {}}, mode)96 self.platforms = platforms97 self._kpi_title: str | None = None98 available = [p for p in platforms if p.get("dashboard")]99 missing = [p for p in platforms if not p.get("dashboard")]100101 def _vol(p: dict) -> float:102 k = main_kpi(p.get("dashboard"))103 return float(k["value"]) if k else 0.0104105 self.ranked = sorted(available, key=_vol, reverse=True) + missing106107 # Le moteur v2 titre toujours le bloc KPI « Synthèse des indicateurs » ;108 # dans le rapport écosystème chaque bloc doit porter son propre titre.109 def _section_title(self, title):110 if self._kpi_title and title == "Synthèse des indicateurs":111 title = self._kpi_title112 super()._section_title(title)113114 def _kpis_titled(self, title: str):115 self._kpi_title = title116 try:117 self._kpis()118 finally:119 self._kpi_title = None120121 # ------------------------------------------------------------- couverture122 def _cover(self):123 p = self.pdf124 p.cover_mode = True125 p.set_auto_page_break(False)126 p.add_page()127 p.set_fill_color(*PAPER)128 p.rect(0, 0, 210, 297, style="F")129 p.set_draw_color(*INK)130 p.set_line_width(1.0)131 p.rect(10, 10, 190, 277)132 p.set_font("helvetica", "B", 10)133 p.set_text_color(*GREEN)134 p.set_xy(24, 34)135 p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")136 # wordmark « Groupe [KA] » : boîte encre, lettres lime137 p.set_xy(24, 70)138 p.set_font("helvetica", "B", 40)139 p.set_text_color(*INK)140 p.cell(p.get_string_width("Groupe") + 2, 20, "Groupe")141 bw = p.get_string_width("KA") + 12142 x = p.get_x() + 2143 p.set_fill_color(*INK)144 p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)145 p.set_text_color(*self.accent)146 p.set_xy(x + 6, 70)147 p.cell(bw - 12, 18, "KA")148 p.set_xy(24, 100)149 p.set_font("helvetica", "B", 16)150 p.set_text_color(*INK)151 p.cell(0, 8, f"RAPPORT ÉCOSYSTÈME — {len(self.platforms)} plateformes")152 p.set_xy(24, 109)153 p.set_font("helvetica", "", 11)154 p.set_text_color(*INK2)155 p.cell(0, 6, "Consolidation des plateformes de données du Groupe KA")156 from datetime import datetime157 from zoneinfo import ZoneInfo158159 now = datetime.now(ZoneInfo("America/Toronto"))160 per = self.d.get("period", {}) or {}161 joined = sum(1 for pl in self.platforms if pl.get("dashboard"))162 rows = [163 (164 "Période couverte",165 self.period_label166 + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else ""),167 ),168 ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),169 (170 "Plateformes",171 f"{len(self.platforms)} plateformes de données + le hub groupe-ka.com "172 f"({joined}/{len(self.platforms)} jointes)",173 ),174 ("Type de rapport", REPORT_MODES[self.mode]),175 ]176 y = 128177 p.set_font("helvetica", "", 10.5)178 for k, v in rows:179 p.set_xy(24, y)180 p.set_text_color(*INK3)181 p.cell(40, 6, k)182 p.set_text_color(*INK)183 p.set_font("helvetica", "B", 10.5)184 p.cell(0, 6, str(v))185 p.set_font("helvetica", "", 10.5)186 y += 8187 # bande encre au pied188 p.set_fill_color(*INK)189 p.rect(10, 262, 190, 25, style="F")190 p.set_xy(24, 270)191 p.set_font("helvetica", "B", 12)192 p.set_text_color(255, 255, 255)193 p.cell(60, 8, "par Groupe ")194 p.set_text_color(*self.accent)195 p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)196 p.cell(20, 8, "KA")197 p.set_font("helvetica", "B", 10)198 p.set_xy(24, 270)199 p.set_text_color(*self.accent)200 p.cell(162, 8, "groupe-ka.com", align="R")201 p.set_auto_page_break(True, margin=22)202 p.cover_mode = False203204 # ---------------------------------------------------------- consolidation205 def _consolidation(self):206 p = self.pdf207 available = [pl for pl in self.ranked if pl.get("dashboard")]208 missing = [pl for pl in self.ranked if not pl.get("dashboard")]209 total_vol = 0.0210 total_new = 0.0211 has_new = 0212 for pl in available:213 mk = main_kpi(pl["dashboard"])214 if mk:215 total_vol += float(mk["value"])216 nk = new_kpi(pl["dashboard"])217 if nk:218 total_new += float(nk["value"])219 has_new += 1220 kpis: list[dict[str, Any]] = [221 {222 "id": "vol",223 "label": "Volume principal agrégé (somme des indicateurs principaux)",224 "value": int(total_vol),225 },226 {227 "id": "joined",228 "label": "Plateformes de données jointes au moment de la génération",229 "value": len(available),230 "unit": f"/ {len(self.platforms)}",231 },232 ]233 if has_new:234 kpis.append(235 {236 "id": "new",237 "label": f"Nouveautés sur la période ({has_new} plateformes qui les mesurent)",238 "value": int(total_new),239 }240 )241 saved = self.d242 self.d = {"kpis": kpis}243 self._kpis_titled("Consolidation de l'écosystème")244 self.d = saved245246 rows: list[list[Any]] = []247 for i, pl in enumerate(self.ranked, 1):248 s = pl["site"]249 dash = pl.get("dashboard")250 if dash:251 mk = main_kpi(dash)252 nk = new_kpi(dash)253 rows.append(254 [255 f"{i}. {s.get('wordmark', s.get('id', ''))}",256 str(mk.get("label", ""))[:34] if mk else "—",257 mk["value"] if mk else "—",258 nk["value"] if nk else "—",259 ]260 )261 else:262 rows.append(263 [264 f"{i}. {s.get('wordmark', s.get('id', ''))}",265 "données indisponibles",266 "—",267 "—",268 ]269 )270 self._table(271 {272 "id": "classement",273 "title": "Classement des plateformes (volume principal)",274 "columns": [275 "Plateforme",276 "Indicateur principal",277 "Volume",278 "Nouveautés (période)",279 ],280 "rows": rows,281 }282 )283 p.set_font("helvetica", "", 7.6)284 p.set_text_color(*INK3)285 note = (286 "Le « volume principal » est le premier indicateur du tableau de bord de "287 "chaque plateforme (annonces actives, véhicules, produits, pages indexées…). "288 "Les volumes ne sont pas de même nature : la somme donne un ordre de "289 "grandeur de l'écosystème, pas une métrique homogène. Données lues en "290 "direct sur chaque plateforme (cache 10 minutes)."291 )292 if missing:293 names = ", ".join(pl["site"].get("wordmark", "") for pl in missing)294 note += f" Plateformes injoignables au moment de la génération : {names}."295 p.multi_cell(174, 4.2, note)296 p.ln(4)297298 # ------------------------------------------------- section par plateforme299 def _platform_header(self, site: dict, accent: tuple):300 """Titre de section : pastille accent + wordmark (boîte encre) + domaine."""301 p = self.pdf302 y = p.get_y() + 2303 p.set_fill_color(*accent)304 p.set_draw_color(*INK)305 p.set_line_width(0.45)306 p.rect(p.l_margin, y + 2, 6, 6, style="DF", round_corners=True, corner_radius=1.5)307 wm = site.get("wordmark", site.get("id", ""))308 left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)309 p.set_xy(p.l_margin + 9, y - 1)310 p.set_font("helvetica", "B", 19)311 p.set_text_color(*INK)312 p.cell(p.get_string_width(left) + 1.5, 11, left)313 if boxed:314 bw = p.get_string_width(boxed) + 7315 bx = p.get_x() + 1316 p.set_fill_color(*INK)317 p.rect(bx, y - 0.6, bw, 10.4, style="F", round_corners=True, corner_radius=2)318 p.set_text_color(*accent)319 p.set_xy(bx + 3.5, y - 1)320 p.cell(bw - 7, 11, boxed)321 p.set_font("helvetica", "B", 9)322 p.set_text_color(*INK3)323 p.set_xy(p.l_margin, y + 2)324 p.cell(174, 6, "https://" + site.get("domain", ""), align="R")325 p.set_y(y + 12)326 if site.get("tagline"):327 p.set_x(p.l_margin)328 p.set_font("helvetica", "", 9)329 p.set_text_color(*INK2)330 p.cell(0, 5, str(site["tagline"]))331 p.ln(7)332333 def _platform_section(self, pl: dict):334 p = self.pdf335 site = pl["site"]336 dash = pl.get("dashboard")337 accent = _hex(site.get("accent", "#d9f26b"))338 p.add_page()339 self._platform_header(site, accent)340 if not dash:341 y = p.get_y() + 2342 self._card(p.l_margin, y, 174, 26, fill=SURFACE2)343 p.set_xy(p.l_margin + 6, y + 5)344 p.set_font("helvetica", "B", 11)345 p.set_text_color(*INK2)346 p.cell(0, 6, "Données indisponibles")347 p.set_xy(p.l_margin + 6, y + 12)348 p.set_font("helvetica", "", 8.6)349 p.set_text_color(*INK3)350 p.multi_cell(351 162,352 4.4,353 f"La plateforme n'a pas répondu au moment de la génération "354 f"({pl.get('error') or 'erreur inconnue'}). Ses statistiques restent "355 f"consultables sur https://{site.get('domain', '')}/stats.",356 )357 p.set_y(y + 30)358 return359 # fraîcheur de la plateforme360 upd = dash.get("updated")361 if upd:362 p.set_x(p.l_margin)363 p.set_font("helvetica", "", 7.6)364 p.set_text_color(*INK3)365 p.cell(0, 4, f"Mis à jour : {str(upd)[:19].replace('T', ' à ')}")366 p.ln(6)367 # réutilise le moteur v2 avec l'identité et le dashboard de la plateforme368 saved = (self.d, self.site, self.accent)369 self.d, self.site, self.accent = dash, site, accent370 wm = site.get("wordmark", "")371 try:372 if self.mode == "tendances":373 self._kpis_titled("Indicateurs — " + wm)374 if dash.get("series") or dash.get("multiseries") or dash.get("stacked"):375 self._section_title("Évolution & tendances — " + wm)376 self._all_series(with_stats=True)377 elif self.mode == "repartitions":378 if (379 dash.get("breakdowns")380 or dash.get("distributions")381 or dash.get("geo")382 or dash.get("hourly")383 ):384 self._section_title("Répartitions & distributions — " + wm)385 self._all_breakdowns()386 else:387 self._kpis_titled("Indicateurs — " + wm)388 elif self.mode == "donnees":389 tables = dash.get("tables") or []390 if tables:391 for t in tables:392 self._table(t, max_rows=200)393 else:394 self._kpis_titled("Indicateurs — " + wm)395 else: # complet396 self._kpis_titled("Indicateurs — " + wm)397 self._gauges()398 self._records()399 finally:400 self.d, self.site, self.accent = saved401402 # ------------------------------------------------------------------ build403 def build(self) -> bytes:404 p = self.pdf405 p.alias_nb_pages()406 self._cover()407 p.add_page()408 self._consolidation()409 if self.mode != "synthese":410 for pl in self.ranked:411 self._platform_section(pl)412 self._final_page()413 return bytes(p.output())414