SPB Git

spb/food-ka Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

Python 57.7% TypeScript 24.9% CSS 16.7% HTML 0.6%
31.5 KB · 805 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# pdfgen.py : génération PDF au style « éditorial sharp — marché frais »5#   rapport_pdf() : rapport global du marché — miroir exact de la page6#   Statistiques du site (tuiles héro, panier comparatif, bannières en7#   chiffres, matrice catégorie × bannière, distribution des prix, baisses8#   de prix 7 j, meilleures aubaines, journal des synchronisations).9#   Palette identique au site : papier crème #faf6ee, encre #14231a,10#   vert marché #1f7a4d, lime #d9f26b, tomate #e8542f. Logos des bannières11#   dessinés dans les en-têtes de tableaux (frontend/public/logos/*.png).12#   Polices de base (Helvetica/Courier) stylées par la mise en page —13#   rectangles à bordure épaisse et ombre décalée pleine, aucun emoji.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import io18import time19from pathlib import Path2021from reportlab.lib.colors import HexColor22from reportlab.lib.pagesizes import letter23from reportlab.lib.utils import ImageReader24from reportlab.pdfgen import canvas as rl_canvas2526from . import db2728# --- palette Food-Ka (celle du site) -----------------------------------------29PAPER = HexColor("#faf6ee")30SURFACE = HexColor("#f7f2e7")31WHITE = HexColor("#ffffff")32INK = HexColor("#14231a")33INK2 = HexColor("#46564b")34INK3 = HexColor("#7d8a80")35GREEN = HexColor("#1f7a4d")36GREEN_DEEP = HexColor("#14523a")37GREEN_SOFT = HexColor("#ddeedd")     # ≈ rgba(46,158,99,.16) sur crème38LIME = HexColor("#d9f26b")39TOMATO = HexColor("#e8542f")40TOMATO_SOFT = HexColor("#fbe3da")4142PAGE_W, PAGE_H = letter43M = 40                                # marge44NBSP = " "45LOGOS_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "logos"4647# Noms d'affichage des bannières (mêmes que le frontend)48SOURCE_NAMES = {49    "metro": "Metro", "superc": "Super C", "iga": "IGA", "maxi": "Maxi",50    "provigo": "Provigo", "walmart": "Walmart", "costco": "Costco",51    "adonis": "Adonis", "pa": "PA", "giant_tiger": "Giant Tiger",52    "epipresto": "Epipresto", "nuvo": "Nuvo", "boite_a_grains": "Boîte à Grains",53    "bocoboco": "BocoBoco", "aliments_merci": "Aliments Merci",54    "mayrand": "Mayrand", "aubut": "Aubut", "maturin": "Maturin",55    "avril": "Avril", "tau": "Tau",56}575859def _nom(src: str) -> str:60    return SOURCE_NAMES.get(src, src.replace("_", " ").title())616263# --- formats fr-CA ------------------------------------------------------------6465def _fmt_i(n) -> str:66    return f"{n:,}".replace(",", NBSP) if n is not None else "—"676869def _fmt_money(v) -> str:70    if v is None:71        return "—"72    return f"{v:,.2f}".replace(",", NBSP).replace(".", ",") + f"{NBSP}$"737475def _fmt_pct(v, digits: int = 0) -> str:76    if v is None:77        return "—"78    txt = f"{v:.{digits}f}".replace(".", ",")79    return f"{txt}{NBSP}%"808182# --- logos des bannières --------------------------------------------------------8384_LOGO_CACHE: dict[str, tuple[ImageReader, float] | None] = {}858687def _logo(src: str) -> tuple[ImageReader, float] | None:88    """ImageReader du logo (aplati sur blanc) + ratio largeur/hauteur.89    Certains fichiers .png sont en réalité des JPEG — Pillow s'en moque."""90    if src in _LOGO_CACHE:91        return _LOGO_CACHE[src]92    out = None93    try:94        from PIL import Image95        p = LOGOS_DIR / f"{src}.png"96        if p.exists():97            im = Image.open(p).convert("RGBA")98            fond = Image.new("RGBA", im.size, (255, 255, 255, 255))99            im = Image.alpha_composite(fond, im).convert("RGB")100            b = io.BytesIO()101            im.save(b, format="PNG")102            out = (ImageReader(io.BytesIO(b.getvalue())), im.width / im.height)103    except Exception:104        out = None105    _LOGO_CACHE[src] = out106    return out107108109def _initiales(src: str) -> str:110    mots = [m for m in _nom(src).replace("·", " ").split() if m]111    return "".join(m[0] for m in mots[:2]).upper() or "?"112113114def _dessine_logo(c: rl_canvas.Canvas, src: str, x: float, y: float,115                  w: float, h: float, cadre: bool = True):116    """Logo de la bannière dans une boîte blanche (x,y = coin bas-gauche).117    Repli gracieux : jeton vert avec initiales si le fichier manque."""118    lg = _logo(src)119    if cadre:120        c.setFillColor(WHITE)121        c.setStrokeColor(INK)122        c.setLineWidth(0.8)123        c.roundRect(x, y, w, h, 2, stroke=1, fill=1)124    if lg is not None:125        img, ratio = lg126        iw, ih = w - 4, h - 4127        if iw / ih > ratio:128            iw = ih * ratio129        else:130            ih = iw / ratio131        try:132            c.drawImage(img, x + (w - iw) / 2, y + (h - ih) / 2, iw, ih, mask="auto")133            return134        except Exception:135            pass136    # repli : jeton coloré avec initiales137    c.setFillColor(GREEN)138    c.roundRect(x + 1.5, y + 1.5, w - 3, h - 3, 2, stroke=0, fill=1)139    c.setFillColor(LIME)140    c.setFont("Helvetica-Bold", min(8.0, h * 0.42))141    c.drawCentredString(x + w / 2, y + h / 2 - min(8.0, h * 0.42) * 0.36, _initiales(src))142143144def _en_tete_bannière(c: rl_canvas.Canvas, src: str, x: float, y_top: float,145                      w: float, box_h: float = 24) -> float:146    """En-tête de colonne : logo dans une boîte blanche + nom court dessous.147    Retourne la hauteur totale occupée."""148    bw = min(w - 4, 40)149    _dessine_logo(c, src, x + (w - bw) / 2, y_top - box_h, bw, box_h)150    c.setFont("Helvetica-Bold", 5.4)151    c.setFillColor(INK2)152    nom = _nom(src)153    while c.stringWidth(nom, "Helvetica-Bold", 5.4) > w - 2 and len(nom) > 3:154        nom = nom[:-2] + "…"155    c.drawCentredString(x + w / 2, y_top - box_h - 7.5, nom)156    return box_h + 11157158159# --- composants du style « éditorial sharp — marché frais » ---------------------160161class _Style:162    def __init__(self, c: rl_canvas.Canvas):163        self.c = c164165    def fond(self):166        self.c.setFillColor(PAPER)167        self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1)168169    def logo(self, x: float, y: float, taille: float = 22) -> float:170        """Wordmark Food·Ka : « Food » encre + boîte encre avec « Ka » lime."""171        c = self.c172        c.setFont("Helvetica-Bold", taille)173        c.setFillColor(INK)174        c.drawString(x, y, "Food")175        w = c.stringWidth("Food", "Helvetica-Bold", taille)176        bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8177        c.saveState()178        c.translate(x + w + 3 + bw / 2, y + taille * 0.32)179        c.rotate(-3)180        c.setFillColor(INK)181        c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 3, stroke=0, fill=1)182        c.setFillColor(LIME)183        c.drawCentredString(0, -taille * 0.30, "Ka")184        c.restoreState()185        return x + w + 6 + bw186187    def entete(self, titre: str) -> float:188        c = self.c189        self.logo(M, PAGE_H - M - 16)190        c.setFont("Courier-Bold", 8)191        c.setFillColor(INK3)192        c.drawRightString(PAGE_W - M, PAGE_H - M - 6, titre.upper())193        c.drawRightString(PAGE_W - M, PAGE_H - M - 16,194                          time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.FOOD-KA.COM"))195        c.setStrokeColor(INK)196        c.setLineWidth(2)197        c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26)198        return PAGE_H - M - 44199200    def pied(self, page: int):201        c = self.c202        c.setStrokeColor(INK)203        c.setLineWidth(1)204        c.line(M, M + 16, PAGE_W - M, M + 16)205        c.setFont("Helvetica", 6.5)206        c.setFillColor(INK3)207        c.drawString(M, M + 6, "Food·Ka — www.food-ka.com — © 2026 Simon-Pierre Boucher")208        c.setFont("Courier-Bold", 7)209        c.drawRightString(PAGE_W - M, M + 6, f"P.{page}")210211    def titre_section(self, y: float, texte: str) -> float:212        c = self.c213        c.setFont("Helvetica-Bold", 12.5)214        c.setFillColor(INK)215        c.drawString(M, y, texte.upper())216        c.setStrokeColor(LIME)217        c.setLineWidth(3)218        c.line(M, y - 4, M + c.stringWidth(texte.upper(), "Helvetica-Bold", 12.5), y - 4)219        return y - 12220221    def sous_titre(self, y: float, texte: str) -> float:222        c = self.c223        c.setFont("Helvetica", 7.5)224        c.setFillColor(INK3)225        c.drawString(M, y, texte)226        return y - 14227228    def pilule(self, x: float, y: float, texte: str, fg=LIME, bg=INK,229               stroke=None, taille: float = 8) -> float:230        c = self.c231        w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14232        c.setFillColor(bg)233        if stroke:234            c.setStrokeColor(stroke)235            c.setLineWidth(1.2)236        c.roundRect(x, y, w, taille + 9, 3, stroke=1 if stroke else 0, fill=1)237        c.setFillColor(fg)238        c.setFont("Helvetica-Bold", taille)239        c.drawString(x + 7, y + 5, texte)240        return x + w + 6241242    def boite_sharp(self, x: float, y: float, w: float, h: float,243                    fill=SURFACE, offset: float = 3, lw: float = 1.5):244        """Rectangle à bordure épaisse avec ombre décalée pleine (encre)."""245        c = self.c246        c.setFillColor(INK)247        c.rect(x + offset, y - offset, w, h, stroke=0, fill=1)248        c.setFillColor(fill)249        c.setStrokeColor(INK)250        c.setLineWidth(lw)251        c.rect(x, y, w, h, stroke=1, fill=1)252253254class _Rapport:255    """Flux multi-pages : suit y, saute de page quand l'espace manque."""256257    def __init__(self, c: rl_canvas.Canvas, s: _Style):258        self.c, self.s = c, s259        self.page = 1260        self.y = 0.0261262    def saut(self, titre: str = "Rapport du marché"):263        self.s.pied(self.page)264        self.c.showPage()265        self.page += 1266        self.s.fond()267        self.y = self.s.entete(titre)268269    def besoin(self, h: float):270        if self.y - h < M + 30:271            self.saut()272273274# ---------------------------------------------------------------------------275# Rapport global du marché276# ---------------------------------------------------------------------------277278def rapport_pdf() -> bytes:279    """Rapport global du marché de l'épicerie Food-Ka (multi-pages, Letter).280281    Tous les chiffres viennent de marketstats.compute() — la même source282    que la page Statistiques du site — plus les aubaines et le journal des283    synchronisations de /api/stats.284    """285    from . import marketstats286    st = marketstats.compute()287    g = st["global"]288289    con = db.connect()290    deals = [dict(r) for r in con.execute(291        """SELECT name, source, price, regular_price FROM products292           WHERE active=1 AND regular_price IS NOT NULL AND price IS NOT NULL293             AND price > 0 AND regular_price > price294           ORDER BY (regular_price - price) / regular_price DESC LIMIT 10""")]295    syncs = [dict(r) for r in con.execute(296        "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 10")]297    con.close()298299    buf = io.BytesIO()300    c = rl_canvas.Canvas(buf, pagesize=letter)301    c.setTitle("Food-Ka — Rapport du marché de l'épicerie")302    s = _Style(c)303    r = _Rapport(c, s)304305    # ======================= page 1 : couverture ============================306    s.fond()307    s.logo(M, PAGE_H - 128, 42)308    c.setFont("Courier-Bold", 9)309    c.setFillColor(GREEN)310    c.drawString(M, PAGE_H - 152, "OBSERVATOIRE — PRIX D'ÉPICERIE AU QUÉBEC")311    c.setFont("Helvetica-Bold", 26)312    c.setFillColor(INK)313    c.drawString(M, PAGE_H - 186, "Rapport du marché de l'épicerie")314    c.setFont("Helvetica", 10)315    c.setFillColor(INK2)316    c.drawString(M, PAGE_H - 204,317                 f"L'épicerie, en chiffres — calculé en direct sur les {_fmt_i(g['total'])} "318                 f"produits actifs de {_fmt_i(g['sources'])} bannières,")319    c.drawString(M, PAGE_H - 217,320                 f"répartis dans {_fmt_i(g['categories'])} catégories et "321                 f"{_fmt_i(g['brands'])} marques.")322323    # --- bandeau façon reçu de caisse ---------------------------------------324    rx, rw, rh = M, PAGE_W - 2 * M, 96325    ry = PAGE_H - 240 - rh326    c.setFillColor(INK)327    c.rect(rx + 4, ry - 4, rw, rh, stroke=0, fill=1)          # ombre décalée328    c.setFillColor(WHITE)329    c.setStrokeColor(INK)330    c.setLineWidth(1.5)331    c.setDash(4, 3)                                            # bord perforé332    c.rect(rx, ry, rw, rh, stroke=1, fill=1)333    c.setDash()334    c.setFillColor(INK)335    c.setFont("Courier-Bold", 9)336    c.drawCentredString(PAGE_W / 2, ry + rh - 18, "* * *  FOOD-KA — MARCHÉ FRAIS  * * *")337    c.setFont("Courier", 8)338    lignes_recu = [339        time.strftime("RAPPORT GÉNÉRÉ LE %Y-%m-%d"),340        f"PRODUITS SUIVIS ............. {_fmt_i(g['total'])}",341        f"BANNIÈRES CONNECTÉES ........ {_fmt_i(g['sources'])}",342        f"PRIX MÉDIAN GLOBAL .......... {_fmt_money(g['median_price'])}",343        "MERCI ! À BIENTÔT",344    ]345    yy = ry + rh - 32346    for lg in lignes_recu:347        c.drawCentredString(PAGE_W / 2, yy, lg)348        yy -= 11349    # code-barres décoratif350    bx = PAGE_W / 2 - 50351    for i in range(40):352        w_bar = 1.4 if i % 3 else 2.6353        c.setFillColor(INK)354        c.rect(bx, ry + 5, w_bar, 6, stroke=0, fill=1)355        bx += w_bar + 1.1356357    # --- tuiles héro (6) ------------------------------------------------------358    tuiles = [359        (_fmt_i(g["total"]), "produits suivis", True),360        (_fmt_pct(g["sale_share"] * 100), f"en solde ({_fmt_i(g['on_sale'])} produits)", False),361        (_fmt_i(g["sources"]), "bannières connectées", False),362        (_fmt_i(g["brands"]), "marques", False),363        (_fmt_money(g["median_price"]), "prix médian global", False),364        (_fmt_i(g["price_changes_7d"]), "changements de prix (7 j)", False),365    ]366    tw = (PAGE_W - 2 * M - 2 * 12) / 3367    th = 56368    ty0 = ry - 34369    for i, (val, lab, hero) in enumerate(tuiles):370        tx = M + (i % 3) * (tw + 12)371        ty = ty0 - (i // 3) * (th + 14) - th372        s.boite_sharp(tx, ty, tw, th, fill=GREEN_DEEP if hero else SURFACE)373        c.setFont("Helvetica-Bold", 16)374        c.setFillColor(LIME if hero else INK)375        c.drawString(tx + 10, ty + 28, val)376        c.setFont("Courier", 6.6)377        c.setFillColor(HexColor("#bcd8c6") if hero else INK3)378        c.drawString(tx + 10, ty + 12, lab.upper())379    y = ty0 - 2 * (th + 14) - 24380381    # --- sommaire ------------------------------------------------------------382    y = s.titre_section(y, "Au sommaire")383    y -= 8384    sommaire = [385        "Panier comparatif — articles courants × bannières",386        "Bannières en chiffres — prix médians, soldes et rabais",387        "Prix médian par catégorie et bannière",388        "Distribution des prix · Baisses de prix (7 jours)",389        "Meilleures aubaines du moment · Journal des synchronisations",390    ]391    for item in sommaire:392        c.setFillColor(GREEN)393        c.rect(M, y - 1, 5, 5, stroke=0, fill=1)394        c.setFont("Helvetica", 9)395        c.setFillColor(INK2)396        c.drawString(M + 12, y, item)397        y -= 15398399    # ======================= page 2 : panier comparatif ======================400    r.saut("Rapport du marché · panier")401    y = s.titre_section(r.y, "Panier comparatif")402    y = s.sous_titre(y, "articles courants × bannières — prix médian des produits correspondants")403404    basket = st["basket"]405    totals = st["basket_totals"][:8]          # déjà triés du moins cher406    cols = [t["source"] for t in totals]407    gagnant = totals[0] if totals else None408409    if not cols:410        c.setFont("Helvetica-Oblique", 9)411        c.setFillColor(INK3)412        c.drawString(M, y - 12, "Pas encore assez de données pour composer le panier — "413                                "il se remplit à mesure que les bannières sont synchronisées.")414        y -= 34415    else:416        item_w = 96417        col_w = (PAGE_W - 2 * M - item_w) / len(cols)418        row_h = 16.5419        head_h = 38420        n_rows = len(basket)421        table_h = head_h + n_rows * row_h + 22        # + ligne TOTAL422        y_table = y - 6423424        # colonne gagnante teintée vert clair sur toute la hauteur425        if gagnant:426            gx = M + item_w + cols.index(gagnant["source"]) * col_w427            c.setFillColor(GREEN_SOFT)428            c.rect(gx, y_table - table_h, col_w, table_h, stroke=0, fill=1)429430        # en-têtes : logos + noms431        c.setFont("Courier-Bold", 7)432        c.setFillColor(INK3)433        c.drawString(M, y_table - 12, "ARTICLE")434        for j, src in enumerate(cols):435            _en_tete_bannière(c, src, M + item_w + j * col_w, y_table - 2, col_w, box_h=24)436        c.setStrokeColor(INK)437        c.setLineWidth(1.2)438        c.line(M, y_table - head_h, PAGE_W - M, y_table - head_h)439440        # rangées : le moins cher de chaque ligne en gras vert441        yy = y_table - head_h - 12442        for row in basket:443            vals = [row["by_source"].get(src, {}).get("median_price") for src in cols]444            present = [v for v in vals if v is not None]445            mini = min(present) if present else None446            c.setFont("Helvetica-Bold", 8.2)447            c.setFillColor(INK)448            c.drawString(M, yy, row["item"])449            for j, v in enumerate(vals):450                cx = M + item_w + j * col_w + col_w - 5451                if v is None:452                    c.setFont("Helvetica", 8)453                    c.setFillColor(INK3)454                    c.drawRightString(cx, yy, "—")455                elif mini is not None and v == mini:456                    c.setFont("Helvetica-Bold", 8.2)457                    c.setFillColor(GREEN)458                    c.drawRightString(cx, yy, _fmt_money(v))459                else:460                    c.setFont("Helvetica", 8)461                    c.setFillColor(INK2)462                    c.drawRightString(cx, yy, _fmt_money(v))463            c.setStrokeColor(INK3)464            c.setLineWidth(0.3)465            c.line(M, yy - 5, PAGE_W - M, yy - 5)466            yy -= row_h467468        # ligne TOTAL469        c.setStrokeColor(INK)470        c.setLineWidth(1.4)471        c.line(M, yy + row_h - 5, PAGE_W - M, yy + row_h - 5)472        yy -= 2473        c.setFont("Helvetica-Bold", 8.4)474        c.setFillColor(INK)475        c.drawString(M, yy, "Total du panier")476        par_src = {t["source"]: t for t in totals}477        for j, src in enumerate(cols):478            t = par_src.get(src)479            cx = M + item_w + j * col_w + col_w - 5480            gagne = gagnant and src == gagnant["source"]481            c.setFont("Helvetica-Bold", 8.6)482            c.setFillColor(GREEN_DEEP if gagne else INK)483            c.drawRightString(cx, yy, _fmt_money(t["total"]) if t else "—")484            if t:485                c.setFont("Helvetica", 5.6)486                c.setFillColor(GREEN if gagne else INK3)487                c.drawRightString(cx, yy - 8, f"{t['items']}/{len(basket)} articles")488        y = yy - 26489490        # médaille dessinée (remplace le trophée emoji) + mention gagnante491        if gagnant:492            c.setFillColor(LIME)493            c.setStrokeColor(INK)494            c.setLineWidth(1.2)495            c.circle(M + 7, y + 5, 7, stroke=1, fill=1)496            c.setFillColor(INK)497            c.setFont("Helvetica-Bold", 8.5)498            c.drawCentredString(M + 7, y + 2, "1")499            c.setFont("Helvetica-Bold", 9.5)500            c.setFillColor(GREEN_DEEP)501            c.drawString(M + 20, y + 1,502                         f"Panier le moins cher : {_nom(gagnant['source'])} — "503                         f"{_fmt_money(gagnant['total'])} pour {gagnant['items']} articles")504            y -= 16505        c.setFont("Helvetica", 7)506        c.setFillColor(INK3)507        c.drawString(M, y, "Prix médian des produits correspondant à chaque article chez la "508                           "bannière ; seules les bannières couvrant la majorité du panier "509                           "sont comparées.")510        y -= 18511    r.y = y512513    # ======================= page 3 : bannières en chiffres ==================514    r.saut("Rapport du marché · bannières")515    y = s.titre_section(r.y, "Bannières en chiffres")516    y = s.sous_titre(y, "prix médian, part de soldes et rabais par bannière")517518    rows = st["by_source"]519    max_median = max((b["median_price"] or 0) for b in rows) or 0.01520    c.setFont("Courier-Bold", 7)521    c.setFillColor(INK3)522    c.drawString(M, y - 8, "BANNIÈRE")523    c.drawRightString(M + 230, y - 8, "PRODUITS")524    c.drawRightString(M + 300, y - 8, "PRIX MÉDIAN")525    c.drawRightString(M + 420, y - 8, "EN SOLDE")526    c.drawRightString(M + 472, y - 8, "RAB. MOYEN")527    c.drawRightString(PAGE_W - M, y - 8, "RAB. MAX")528    c.setStrokeColor(INK)529    c.setLineWidth(1.2)530    c.line(M, y - 13, PAGE_W - M, y - 13)531    yy = y - 27532    for b in rows:533        _dessine_logo(c, b["source"], M, yy - 4, 20, 13)534        c.setFont("Helvetica-Bold", 8.2)535        c.setFillColor(INK)536        c.drawString(M + 26, yy, _nom(b["source"])[:24])537        c.setFont("Helvetica", 8.2)538        c.setFillColor(INK2)539        c.drawRightString(M + 230, yy, _fmt_i(b["n"]))540        c.drawRightString(M + 300, yy, _fmt_money(b["median_price"]))541        # mini-barre du prix médian542        bx0, bw_ = M + 310, 58543        c.setFillColor(WHITE)544        c.setStrokeColor(INK)545        c.setLineWidth(0.5)546        c.rect(bx0, yy - 1, bw_, 7, stroke=1, fill=1)547        c.setFillColor(GREEN)548        c.rect(bx0, yy - 1, bw_ * min(1.0, (b["median_price"] or 0) / max_median),549               7, stroke=0, fill=1)550        c.setFont("Helvetica", 8.2)551        c.setFillColor(TOMATO if b["sale_share"] > 0 else INK3)552        c.drawRightString(M + 420, yy, _fmt_pct(b["sale_share"] * 100))553        c.setFillColor(INK2)554        c.drawRightString(M + 472, yy, _fmt_pct(b["avg_discount_pct"], 1))555        c.drawRightString(PAGE_W - M, yy, _fmt_pct(b["max_discount_pct"], 1))556        c.setStrokeColor(INK3)557        c.setLineWidth(0.3)558        c.line(M, yy - 6, PAGE_W - M, yy - 6)559        yy -= 17.5560    r.y = yy - 8561562    # ======================= page 4 : matrice catégorie × bannière ===========563    r.saut("Rapport du marché · catégories")564    y = s.titre_section(r.y, "Prix médian par catégorie et bannière")565    mat_srcs = [b["source"] for b in rows[:9]]566    y = s.sous_titre(y, f"{len(mat_srcs)} plus grandes bannières — le moins cher "567                        "de chaque rangée en vert")568569    cat_w = 108570    mcol_w = (PAGE_W - 2 * M - cat_w) / max(1, len(mat_srcs))571    y_mat = y - 4572    c.setFont("Courier-Bold", 7)573    c.setFillColor(INK3)574    c.drawString(M, y_mat - 12, "CATÉGORIE")575    for j, src in enumerate(mat_srcs):576        _en_tete_bannière(c, src, M + cat_w + j * mcol_w, y_mat - 2, mcol_w, box_h=22)577    c.setStrokeColor(INK)578    c.setLineWidth(1.2)579    c.line(M, y_mat - 36, PAGE_W - M, y_mat - 36)580581    mat_rows = sorted(582        st["category_matrix"].items(),583        key=lambda kv: -sum(cell["n"] for cell in kv[1].values()))584    row_h = 15.5585    yy = y_mat - 48586    for cat, per in mat_rows:587        if yy < M + 40:588            break589        vals = [per.get(src, {}).get("median_price") for src in mat_srcs]590        present = [v for v in vals if v is not None]591        mini = min(present) if present else None592        maxi_v = max(present) if present else None593        for j, v in enumerate(vals):594            cx0 = M + cat_w + j * mcol_w595            if v is not None and mini is not None:596                if v == mini:597                    c.setFillColor(GREEN_SOFT)598                    c.rect(cx0, yy - 4.5, mcol_w, row_h - 2, stroke=0, fill=1)599                elif maxi_v is not None and maxi_v > mini:600                    t = (v - mini) / (maxi_v - mini)601                    c.saveState()602                    c.setFillColor(TOMATO)603                    c.setFillAlpha(0.05 + 0.18 * t)604                    c.rect(cx0, yy - 4.5, mcol_w, row_h - 2, stroke=0, fill=1)605                    c.restoreState()606        c.setFont("Helvetica-Bold", 7.4)607        c.setFillColor(INK)608        nom_cat = cat if len(cat) <= 26 else cat[:25] + "…"609        c.drawString(M, yy, nom_cat)610        for j, v in enumerate(vals):611            cx = M + cat_w + j * mcol_w + mcol_w - 4612            if v is None:613                c.setFont("Helvetica", 7)614                c.setFillColor(INK3)615                c.drawRightString(cx, yy, "—")616            elif mini is not None and v == mini:617                c.setFont("Helvetica-Bold", 7.2)618                c.setFillColor(GREEN_DEEP)619                c.drawRightString(cx, yy, _fmt_money(v))620            else:621                c.setFont("Helvetica", 7)622                c.setFillColor(INK2)623                c.drawRightString(cx, yy, _fmt_money(v))624        c.setStrokeColor(INK3)625        c.setLineWidth(0.3)626        c.line(M, yy - 4.5, PAGE_W - M, yy - 4.5)627        yy -= row_h628    # légende629    yy -= 6630    c.setFillColor(GREEN_SOFT)631    c.rect(M, yy - 2, 9, 7, stroke=0, fill=1)632    c.setFont("Helvetica", 7)633    c.setFillColor(INK3)634    c.drawString(M + 13, yy, "moins cher")635    c.saveState()636    c.setFillColor(TOMATO)637    c.setFillAlpha(0.22)638    c.rect(M + 70, yy - 2, 9, 7, stroke=0, fill=1)639    c.restoreState()640    c.drawString(M + 83, yy, "plus cher")641    r.y = yy - 16642643    # ======================= distribution des prix ===========================644    r.besoin(160)645    y = s.titre_section(r.y, "Distribution des prix")646    y = s.sous_titre(y, "produits actifs par palier de prix")647    dist = st["price_distribution"]648    dist_max = max((b["n"] for b in dist), default=1) or 1649    yy = y - 8650    for b in dist:651        c.setFont("Helvetica-Bold", 8.2)652        c.setFillColor(INK)653        c.drawString(M, yy, b["range"])654        bx0, bw_ = M + 70, PAGE_W - 2 * M - 130655        c.setFillColor(WHITE)656        c.setStrokeColor(INK)657        c.setLineWidth(0.8)658        c.rect(bx0, yy - 2, bw_, 10, stroke=1, fill=1)659        c.setFillColor(GREEN)660        c.rect(bx0, yy - 2, max(2, bw_ * b["n"] / dist_max), 10, stroke=0, fill=1)661        c.setFont("Courier-Bold", 8)662        c.setFillColor(INK)663        c.drawRightString(PAGE_W - M, yy, _fmt_i(b["n"]))664        yy -= 18665    r.y = yy - 12666667    # ======================= baisses de prix (7 j) ===========================668    drops = st["price_drops"][:20]669    r.besoin(60 + max(1, len(drops)) * 15)670    y = s.titre_section(r.y, "Baisses de prix (7 jours)")671    y = s.sous_titre(y, "produits dont le prix relevé a diminué depuis la dernière synchronisation")672    if not drops:673        c.setFont("Helvetica-Oblique", 9)674        c.setFillColor(INK3)675        c.drawString(M, y - 10, "Aucune baisse détectée encore — l'historique se construit "676                                "à chaque synchronisation.")677        r.y = y - 32678    else:679        yy = y - 10680        for dr in drops:681            r.y = yy682            r.besoin(20)683            yy = r.y684            _dessine_logo(c, dr["source"], M, yy - 3.5, 18, 12)685            c.setFont("Helvetica", 8)686            c.setFillColor(INK)687            c.drawString(M + 24, yy, dr["name"][:52] + ("…" if len(dr["name"]) > 52 else ""))688            # ancien prix barré -> nouveau prix689            ax = M + 300690            c.setFont("Helvetica", 8)691            c.setFillColor(INK3)692            old_txt = _fmt_money(dr["old_price"])693            c.drawString(ax, yy, old_txt)694            ow = c.stringWidth(old_txt, "Helvetica", 8)695            c.setStrokeColor(INK3)696            c.setLineWidth(0.8)697            c.line(ax - 1, yy + 2.6, ax + ow + 1, yy + 2.6)698            # flèche dessinée (pas de glyphe → en Helvetica)699            fx = ax + ow + 6700            c.setStrokeColor(INK2)701            c.setLineWidth(1)702            c.line(fx, yy + 2.6, fx + 10, yy + 2.6)703            c.setFillColor(INK2)704            p = c.beginPath()705            p.moveTo(fx + 10, yy + 5.1)706            p.lineTo(fx + 14, yy + 2.6)707            p.lineTo(fx + 10, yy + 0.1)708            p.close()709            c.drawPath(p, stroke=0, fill=1)710            c.setFont("Helvetica-Bold", 8.4)711            c.setFillColor(GREEN_DEEP)712            c.drawString(fx + 19, yy, _fmt_money(dr["new_price"]))713            # pourcentage en tomate714            pct = "-" + f"{dr['drop_pct']:.1f}".replace(".", ",") + f"{NBSP}%"715            pw = c.stringWidth(pct, "Helvetica-Bold", 7.5) + 10716            c.setFillColor(TOMATO_SOFT)717            c.setStrokeColor(TOMATO)718            c.setLineWidth(1)719            c.roundRect(PAGE_W - M - pw, yy - 3.5, pw, 13, 2, stroke=1, fill=1)720            c.setFillColor(TOMATO)721            c.setFont("Helvetica-Bold", 7.5)722            c.drawRightString(PAGE_W - M - 5, yy, pct)723            yy -= 16.5724        r.y = yy - 10725726    # ======================= meilleures aubaines =============================727    if deals:728        r.besoin(70 + len(deals) * 17)729        y = s.titre_section(r.y, "Meilleures aubaines du moment")730        y = s.sous_titre(y, "rabais relatif le plus fort, toutes bannières confondues — top 10")731        yy = y - 10732        for d_ in deals:733            pct_v = 100 * (d_["regular_price"] - d_["price"]) / d_["regular_price"]734            _dessine_logo(c, d_["source"], M, yy - 3.5, 18, 12)735            c.setFont("Helvetica", 8)736            c.setFillColor(INK)737            nom_p = d_["name"] or ""738            c.drawString(M + 24, yy, nom_p[:46] + ("…" if len(nom_p) > 46 else ""))739            c.setFont("Helvetica", 7)740            c.setFillColor(INK3)741            c.drawString(M + 262, yy, _nom(d_["source"])[:16])742            # prix régulier barré + prix soldé743            reg_txt = _fmt_money(d_["regular_price"])744            c.setFont("Helvetica", 8)745            c.setFillColor(INK3)746            rx0 = M + 360747            c.drawString(rx0, yy, reg_txt)748            rw_ = c.stringWidth(reg_txt, "Helvetica", 8)749            c.setStrokeColor(INK3)750            c.setLineWidth(0.8)751            c.line(rx0 - 1, yy + 2.6, rx0 + rw_ + 1, yy + 2.6)752            c.setFont("Helvetica-Bold", 8.6)753            c.setFillColor(GREEN_DEEP)754            c.drawString(rx0 + rw_ + 8, yy, _fmt_money(d_["price"]))755            pct = "-" + f"{pct_v:.0f}".replace(".", ",") + f"{NBSP}%"756            pw = c.stringWidth(pct, "Helvetica-Bold", 7.5) + 10757            c.setFillColor(TOMATO)758            c.roundRect(PAGE_W - M - pw, yy - 3.5, pw, 13, 2, stroke=0, fill=1)759            c.setFillColor(WHITE)760            c.setFont("Helvetica-Bold", 7.5)761            c.drawRightString(PAGE_W - M - 5, yy, pct)762            yy -= 17763        r.y = yy - 10764765    # ======================= journal des synchronisations ====================766    if syncs:767        r.besoin(60 + len(syncs) * 15)768        y = s.titre_section(r.y, "Dernières synchronisations")769        y = s.sous_titre(y, "10 derniers passages des connecteurs de bannières")770        yy = y - 10771        for lg in syncs:772            ok = bool(lg.get("ok"))773            c.setFillColor(GREEN if ok else TOMATO)774            c.circle(M + 4, yy + 2.6, 3.4, stroke=0, fill=1)775            c.setFont("Helvetica-Bold", 8.2)776            c.setFillColor(INK)777            c.drawString(M + 14, yy, _nom(lg["source"])[:20])778            c.setFont("Courier", 7.5)779            c.setFillColor(INK3)780            c.drawString(M + 130, yy, time.strftime("%Y-%m-%d %H:%M",781                                                    time.localtime(lg["ts"])))782            c.setFont("Helvetica", 8)783            c.setFillColor(INK2)784            detail = (f"{_fmt_i(lg['found'])} produits trouvés · "785                      f"{_fmt_i(lg['added'])} ajoutés · {_fmt_i(lg['updated'])} mis à jour · "786                      f"{_fmt_i(lg['removed'])} retirés")787            c.drawString(M + 235, yy, detail)788            c.setFont("Helvetica-Bold", 7.5)789            c.setFillColor(GREEN_DEEP if ok else TOMATO)790            c.drawRightString(PAGE_W - M, yy, "OK" if ok else "ÉCHEC")791            yy -= 15.5792        r.y = yy - 6793794    # note finale795    r.besoin(30)796    c.setFont("Helvetica", 7.5)797    c.setFillColor(INK3)798    c.drawString(M, r.y - 4, "Données recalculées à chaque synchronisation — rapport non "799                             "contractuel, généré automatiquement à partir des prix publiés "800                             "par les bannières.")801802    s.pied(r.page)803    c.save()804    return buf.getvalue()805