SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%
15.3 KB · 414 lines python
Raw Blame History
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