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%
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