# ----------------------------------------------------------------------------- # Food-Ka — Agrégateur de produits d'épicerie (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # pdfgen.py : génération PDF au style « éditorial sharp — marché frais » # rapport_pdf() : rapport global du marché — miroir exact de la page # Statistiques du site (tuiles héro, panier comparatif, bannières en # chiffres, matrice catégorie × bannière, distribution des prix, baisses # de prix 7 j, meilleures aubaines, journal des synchronisations). # Palette identique au site : papier crème #faf6ee, encre #14231a, # vert marché #1f7a4d, lime #d9f26b, tomate #e8542f. Logos des bannières # dessinés dans les en-têtes de tableaux (frontend/public/logos/*.png). # Polices de base (Helvetica/Courier) stylées par la mise en page — # rectangles à bordure épaisse et ombre décalée pleine, aucun emoji. # ----------------------------------------------------------------------------- from __future__ import annotations import io import time from pathlib import Path from reportlab.lib.colors import HexColor from reportlab.lib.pagesizes import letter from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas as rl_canvas from . import db # --- palette Food-Ka (celle du site) ----------------------------------------- PAPER = HexColor("#faf6ee") SURFACE = HexColor("#f7f2e7") WHITE = HexColor("#ffffff") INK = HexColor("#14231a") INK2 = HexColor("#46564b") INK3 = HexColor("#7d8a80") GREEN = HexColor("#1f7a4d") GREEN_DEEP = HexColor("#14523a") GREEN_SOFT = HexColor("#ddeedd") # ≈ rgba(46,158,99,.16) sur crème LIME = HexColor("#d9f26b") TOMATO = HexColor("#e8542f") TOMATO_SOFT = HexColor("#fbe3da") PAGE_W, PAGE_H = letter M = 40 # marge NBSP = " " LOGOS_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "logos" # Noms d'affichage des bannières (mêmes que le frontend) SOURCE_NAMES = { "metro": "Metro", "superc": "Super C", "iga": "IGA", "maxi": "Maxi", "provigo": "Provigo", "walmart": "Walmart", "costco": "Costco", "adonis": "Adonis", "pa": "PA", "giant_tiger": "Giant Tiger", "epipresto": "Epipresto", "nuvo": "Nuvo", "boite_a_grains": "Boîte à Grains", "bocoboco": "BocoBoco", "aliments_merci": "Aliments Merci", "mayrand": "Mayrand", "aubut": "Aubut", "maturin": "Maturin", "avril": "Avril", "tau": "Tau", } def _nom(src: str) -> str: return SOURCE_NAMES.get(src, src.replace("_", " ").title()) # --- formats fr-CA ------------------------------------------------------------ def _fmt_i(n) -> str: return f"{n:,}".replace(",", NBSP) if n is not None else "—" def _fmt_money(v) -> str: if v is None: return "—" return f"{v:,.2f}".replace(",", NBSP).replace(".", ",") + f"{NBSP}$" def _fmt_pct(v, digits: int = 0) -> str: if v is None: return "—" txt = f"{v:.{digits}f}".replace(".", ",") return f"{txt}{NBSP}%" # --- logos des bannières -------------------------------------------------------- _LOGO_CACHE: dict[str, tuple[ImageReader, float] | None] = {} def _logo(src: str) -> tuple[ImageReader, float] | None: """ImageReader du logo (aplati sur blanc) + ratio largeur/hauteur. Certains fichiers .png sont en réalité des JPEG — Pillow s'en moque.""" if src in _LOGO_CACHE: return _LOGO_CACHE[src] out = None try: from PIL import Image p = LOGOS_DIR / f"{src}.png" if p.exists(): im = Image.open(p).convert("RGBA") fond = Image.new("RGBA", im.size, (255, 255, 255, 255)) im = Image.alpha_composite(fond, im).convert("RGB") b = io.BytesIO() im.save(b, format="PNG") out = (ImageReader(io.BytesIO(b.getvalue())), im.width / im.height) except Exception: out = None _LOGO_CACHE[src] = out return out def _initiales(src: str) -> str: mots = [m for m in _nom(src).replace("·", " ").split() if m] return "".join(m[0] for m in mots[:2]).upper() or "?" def _dessine_logo(c: rl_canvas.Canvas, src: str, x: float, y: float, w: float, h: float, cadre: bool = True): """Logo de la bannière dans une boîte blanche (x,y = coin bas-gauche). Repli gracieux : jeton vert avec initiales si le fichier manque.""" lg = _logo(src) if cadre: c.setFillColor(WHITE) c.setStrokeColor(INK) c.setLineWidth(0.8) c.roundRect(x, y, w, h, 2, stroke=1, fill=1) if lg is not None: img, ratio = lg iw, ih = w - 4, h - 4 if iw / ih > ratio: iw = ih * ratio else: ih = iw / ratio try: c.drawImage(img, x + (w - iw) / 2, y + (h - ih) / 2, iw, ih, mask="auto") return except Exception: pass # repli : jeton coloré avec initiales c.setFillColor(GREEN) c.roundRect(x + 1.5, y + 1.5, w - 3, h - 3, 2, stroke=0, fill=1) c.setFillColor(LIME) c.setFont("Helvetica-Bold", min(8.0, h * 0.42)) c.drawCentredString(x + w / 2, y + h / 2 - min(8.0, h * 0.42) * 0.36, _initiales(src)) def _en_tete_bannière(c: rl_canvas.Canvas, src: str, x: float, y_top: float, w: float, box_h: float = 24) -> float: """En-tête de colonne : logo dans une boîte blanche + nom court dessous. Retourne la hauteur totale occupée.""" bw = min(w - 4, 40) _dessine_logo(c, src, x + (w - bw) / 2, y_top - box_h, bw, box_h) c.setFont("Helvetica-Bold", 5.4) c.setFillColor(INK2) nom = _nom(src) while c.stringWidth(nom, "Helvetica-Bold", 5.4) > w - 2 and len(nom) > 3: nom = nom[:-2] + "…" c.drawCentredString(x + w / 2, y_top - box_h - 7.5, nom) return box_h + 11 # --- composants du style « éditorial sharp — marché frais » --------------------- class _Style: def __init__(self, c: rl_canvas.Canvas): self.c = c def fond(self): self.c.setFillColor(PAPER) self.c.rect(0, 0, PAGE_W, PAGE_H, stroke=0, fill=1) def logo(self, x: float, y: float, taille: float = 22) -> float: """Wordmark Food·Ka : « Food » encre + boîte encre avec « Ka » lime.""" c = self.c c.setFont("Helvetica-Bold", taille) c.setFillColor(INK) c.drawString(x, y, "Food") w = c.stringWidth("Food", "Helvetica-Bold", taille) bw = c.stringWidth("Ka", "Helvetica-Bold", taille) + 8 c.saveState() c.translate(x + w + 3 + bw / 2, y + taille * 0.32) c.rotate(-3) c.setFillColor(INK) c.roundRect(-bw / 2, -taille * 0.62, bw, taille * 1.15, 3, stroke=0, fill=1) c.setFillColor(LIME) c.drawCentredString(0, -taille * 0.30, "Ka") c.restoreState() return x + w + 6 + bw def entete(self, titre: str) -> float: c = self.c self.logo(M, PAGE_H - M - 16) c.setFont("Courier-Bold", 8) c.setFillColor(INK3) c.drawRightString(PAGE_W - M, PAGE_H - M - 6, titre.upper()) c.drawRightString(PAGE_W - M, PAGE_H - M - 16, time.strftime("GÉNÉRÉ LE %Y-%m-%d · WWW.FOOD-KA.COM")) c.setStrokeColor(INK) c.setLineWidth(2) c.line(M, PAGE_H - M - 26, PAGE_W - M, PAGE_H - M - 26) return PAGE_H - M - 44 def pied(self, page: int): c = self.c c.setStrokeColor(INK) c.setLineWidth(1) c.line(M, M + 16, PAGE_W - M, M + 16) c.setFont("Helvetica", 6.5) c.setFillColor(INK3) c.drawString(M, M + 6, "Food·Ka — www.food-ka.com — © 2026 Simon-Pierre Boucher") c.setFont("Courier-Bold", 7) c.drawRightString(PAGE_W - M, M + 6, f"P.{page}") def titre_section(self, y: float, texte: str) -> float: c = self.c c.setFont("Helvetica-Bold", 12.5) c.setFillColor(INK) c.drawString(M, y, texte.upper()) c.setStrokeColor(LIME) c.setLineWidth(3) c.line(M, y - 4, M + c.stringWidth(texte.upper(), "Helvetica-Bold", 12.5), y - 4) return y - 12 def sous_titre(self, y: float, texte: str) -> float: c = self.c c.setFont("Helvetica", 7.5) c.setFillColor(INK3) c.drawString(M, y, texte) return y - 14 def pilule(self, x: float, y: float, texte: str, fg=LIME, bg=INK, stroke=None, taille: float = 8) -> float: c = self.c w = c.stringWidth(texte, "Helvetica-Bold", taille) + 14 c.setFillColor(bg) if stroke: c.setStrokeColor(stroke) c.setLineWidth(1.2) c.roundRect(x, y, w, taille + 9, 3, stroke=1 if stroke else 0, fill=1) c.setFillColor(fg) c.setFont("Helvetica-Bold", taille) c.drawString(x + 7, y + 5, texte) return x + w + 6 def boite_sharp(self, x: float, y: float, w: float, h: float, fill=SURFACE, offset: float = 3, lw: float = 1.5): """Rectangle à bordure épaisse avec ombre décalée pleine (encre).""" c = self.c c.setFillColor(INK) c.rect(x + offset, y - offset, w, h, stroke=0, fill=1) c.setFillColor(fill) c.setStrokeColor(INK) c.setLineWidth(lw) c.rect(x, y, w, h, stroke=1, fill=1) class _Rapport: """Flux multi-pages : suit y, saute de page quand l'espace manque.""" def __init__(self, c: rl_canvas.Canvas, s: _Style): self.c, self.s = c, s self.page = 1 self.y = 0.0 def saut(self, titre: str = "Rapport du marché"): self.s.pied(self.page) self.c.showPage() self.page += 1 self.s.fond() self.y = self.s.entete(titre) def besoin(self, h: float): if self.y - h < M + 30: self.saut() # --------------------------------------------------------------------------- # Rapport global du marché # --------------------------------------------------------------------------- def rapport_pdf() -> bytes: """Rapport global du marché de l'épicerie Food-Ka (multi-pages, Letter). Tous les chiffres viennent de marketstats.compute() — la même source que la page Statistiques du site — plus les aubaines et le journal des synchronisations de /api/stats. """ from . import marketstats st = marketstats.compute() g = st["global"] con = db.connect() deals = [dict(r) for r in con.execute( """SELECT name, source, price, regular_price FROM products WHERE active=1 AND regular_price IS NOT NULL AND price IS NOT NULL AND price > 0 AND regular_price > price ORDER BY (regular_price - price) / regular_price DESC LIMIT 10""")] syncs = [dict(r) for r in con.execute( "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 10")] con.close() buf = io.BytesIO() c = rl_canvas.Canvas(buf, pagesize=letter) c.setTitle("Food-Ka — Rapport du marché de l'épicerie") s = _Style(c) r = _Rapport(c, s) # ======================= page 1 : couverture ============================ s.fond() s.logo(M, PAGE_H - 128, 42) c.setFont("Courier-Bold", 9) c.setFillColor(GREEN) c.drawString(M, PAGE_H - 152, "OBSERVATOIRE — PRIX D'ÉPICERIE AU QUÉBEC") c.setFont("Helvetica-Bold", 26) c.setFillColor(INK) c.drawString(M, PAGE_H - 186, "Rapport du marché de l'épicerie") c.setFont("Helvetica", 10) c.setFillColor(INK2) c.drawString(M, PAGE_H - 204, f"L'épicerie, en chiffres — calculé en direct sur les {_fmt_i(g['total'])} " f"produits actifs de {_fmt_i(g['sources'])} bannières,") c.drawString(M, PAGE_H - 217, f"répartis dans {_fmt_i(g['categories'])} catégories et " f"{_fmt_i(g['brands'])} marques.") # --- bandeau façon reçu de caisse --------------------------------------- rx, rw, rh = M, PAGE_W - 2 * M, 96 ry = PAGE_H - 240 - rh c.setFillColor(INK) c.rect(rx + 4, ry - 4, rw, rh, stroke=0, fill=1) # ombre décalée c.setFillColor(WHITE) c.setStrokeColor(INK) c.setLineWidth(1.5) c.setDash(4, 3) # bord perforé c.rect(rx, ry, rw, rh, stroke=1, fill=1) c.setDash() c.setFillColor(INK) c.setFont("Courier-Bold", 9) c.drawCentredString(PAGE_W / 2, ry + rh - 18, "* * * FOOD-KA — MARCHÉ FRAIS * * *") c.setFont("Courier", 8) lignes_recu = [ time.strftime("RAPPORT GÉNÉRÉ LE %Y-%m-%d"), f"PRODUITS SUIVIS ............. {_fmt_i(g['total'])}", f"BANNIÈRES CONNECTÉES ........ {_fmt_i(g['sources'])}", f"PRIX MÉDIAN GLOBAL .......... {_fmt_money(g['median_price'])}", "MERCI ! À BIENTÔT", ] yy = ry + rh - 32 for lg in lignes_recu: c.drawCentredString(PAGE_W / 2, yy, lg) yy -= 11 # code-barres décoratif bx = PAGE_W / 2 - 50 for i in range(40): w_bar = 1.4 if i % 3 else 2.6 c.setFillColor(INK) c.rect(bx, ry + 5, w_bar, 6, stroke=0, fill=1) bx += w_bar + 1.1 # --- tuiles héro (6) ------------------------------------------------------ tuiles = [ (_fmt_i(g["total"]), "produits suivis", True), (_fmt_pct(g["sale_share"] * 100), f"en solde ({_fmt_i(g['on_sale'])} produits)", False), (_fmt_i(g["sources"]), "bannières connectées", False), (_fmt_i(g["brands"]), "marques", False), (_fmt_money(g["median_price"]), "prix médian global", False), (_fmt_i(g["price_changes_7d"]), "changements de prix (7 j)", False), ] tw = (PAGE_W - 2 * M - 2 * 12) / 3 th = 56 ty0 = ry - 34 for i, (val, lab, hero) in enumerate(tuiles): tx = M + (i % 3) * (tw + 12) ty = ty0 - (i // 3) * (th + 14) - th s.boite_sharp(tx, ty, tw, th, fill=GREEN_DEEP if hero else SURFACE) c.setFont("Helvetica-Bold", 16) c.setFillColor(LIME if hero else INK) c.drawString(tx + 10, ty + 28, val) c.setFont("Courier", 6.6) c.setFillColor(HexColor("#bcd8c6") if hero else INK3) c.drawString(tx + 10, ty + 12, lab.upper()) y = ty0 - 2 * (th + 14) - 24 # --- sommaire ------------------------------------------------------------ y = s.titre_section(y, "Au sommaire") y -= 8 sommaire = [ "Panier comparatif — articles courants × bannières", "Bannières en chiffres — prix médians, soldes et rabais", "Prix médian par catégorie et bannière", "Distribution des prix · Baisses de prix (7 jours)", "Meilleures aubaines du moment · Journal des synchronisations", ] for item in sommaire: c.setFillColor(GREEN) c.rect(M, y - 1, 5, 5, stroke=0, fill=1) c.setFont("Helvetica", 9) c.setFillColor(INK2) c.drawString(M + 12, y, item) y -= 15 # ======================= page 2 : panier comparatif ====================== r.saut("Rapport du marché · panier") y = s.titre_section(r.y, "Panier comparatif") y = s.sous_titre(y, "articles courants × bannières — prix médian des produits correspondants") basket = st["basket"] totals = st["basket_totals"][:8] # déjà triés du moins cher cols = [t["source"] for t in totals] gagnant = totals[0] if totals else None if not cols: c.setFont("Helvetica-Oblique", 9) c.setFillColor(INK3) c.drawString(M, y - 12, "Pas encore assez de données pour composer le panier — " "il se remplit à mesure que les bannières sont synchronisées.") y -= 34 else: item_w = 96 col_w = (PAGE_W - 2 * M - item_w) / len(cols) row_h = 16.5 head_h = 38 n_rows = len(basket) table_h = head_h + n_rows * row_h + 22 # + ligne TOTAL y_table = y - 6 # colonne gagnante teintée vert clair sur toute la hauteur if gagnant: gx = M + item_w + cols.index(gagnant["source"]) * col_w c.setFillColor(GREEN_SOFT) c.rect(gx, y_table - table_h, col_w, table_h, stroke=0, fill=1) # en-têtes : logos + noms c.setFont("Courier-Bold", 7) c.setFillColor(INK3) c.drawString(M, y_table - 12, "ARTICLE") for j, src in enumerate(cols): _en_tete_bannière(c, src, M + item_w + j * col_w, y_table - 2, col_w, box_h=24) c.setStrokeColor(INK) c.setLineWidth(1.2) c.line(M, y_table - head_h, PAGE_W - M, y_table - head_h) # rangées : le moins cher de chaque ligne en gras vert yy = y_table - head_h - 12 for row in basket: vals = [row["by_source"].get(src, {}).get("median_price") for src in cols] present = [v for v in vals if v is not None] mini = min(present) if present else None c.setFont("Helvetica-Bold", 8.2) c.setFillColor(INK) c.drawString(M, yy, row["item"]) for j, v in enumerate(vals): cx = M + item_w + j * col_w + col_w - 5 if v is None: c.setFont("Helvetica", 8) c.setFillColor(INK3) c.drawRightString(cx, yy, "—") elif mini is not None and v == mini: c.setFont("Helvetica-Bold", 8.2) c.setFillColor(GREEN) c.drawRightString(cx, yy, _fmt_money(v)) else: c.setFont("Helvetica", 8) c.setFillColor(INK2) c.drawRightString(cx, yy, _fmt_money(v)) c.setStrokeColor(INK3) c.setLineWidth(0.3) c.line(M, yy - 5, PAGE_W - M, yy - 5) yy -= row_h # ligne TOTAL c.setStrokeColor(INK) c.setLineWidth(1.4) c.line(M, yy + row_h - 5, PAGE_W - M, yy + row_h - 5) yy -= 2 c.setFont("Helvetica-Bold", 8.4) c.setFillColor(INK) c.drawString(M, yy, "Total du panier") par_src = {t["source"]: t for t in totals} for j, src in enumerate(cols): t = par_src.get(src) cx = M + item_w + j * col_w + col_w - 5 gagne = gagnant and src == gagnant["source"] c.setFont("Helvetica-Bold", 8.6) c.setFillColor(GREEN_DEEP if gagne else INK) c.drawRightString(cx, yy, _fmt_money(t["total"]) if t else "—") if t: c.setFont("Helvetica", 5.6) c.setFillColor(GREEN if gagne else INK3) c.drawRightString(cx, yy - 8, f"{t['items']}/{len(basket)} articles") y = yy - 26 # médaille dessinée (remplace le trophée emoji) + mention gagnante if gagnant: c.setFillColor(LIME) c.setStrokeColor(INK) c.setLineWidth(1.2) c.circle(M + 7, y + 5, 7, stroke=1, fill=1) c.setFillColor(INK) c.setFont("Helvetica-Bold", 8.5) c.drawCentredString(M + 7, y + 2, "1") c.setFont("Helvetica-Bold", 9.5) c.setFillColor(GREEN_DEEP) c.drawString(M + 20, y + 1, f"Panier le moins cher : {_nom(gagnant['source'])} — " f"{_fmt_money(gagnant['total'])} pour {gagnant['items']} articles") y -= 16 c.setFont("Helvetica", 7) c.setFillColor(INK3) c.drawString(M, y, "Prix médian des produits correspondant à chaque article chez la " "bannière ; seules les bannières couvrant la majorité du panier " "sont comparées.") y -= 18 r.y = y # ======================= page 3 : bannières en chiffres ================== r.saut("Rapport du marché · bannières") y = s.titre_section(r.y, "Bannières en chiffres") y = s.sous_titre(y, "prix médian, part de soldes et rabais par bannière") rows = st["by_source"] max_median = max((b["median_price"] or 0) for b in rows) or 0.01 c.setFont("Courier-Bold", 7) c.setFillColor(INK3) c.drawString(M, y - 8, "BANNIÈRE") c.drawRightString(M + 230, y - 8, "PRODUITS") c.drawRightString(M + 300, y - 8, "PRIX MÉDIAN") c.drawRightString(M + 420, y - 8, "EN SOLDE") c.drawRightString(M + 472, y - 8, "RAB. MOYEN") c.drawRightString(PAGE_W - M, y - 8, "RAB. MAX") c.setStrokeColor(INK) c.setLineWidth(1.2) c.line(M, y - 13, PAGE_W - M, y - 13) yy = y - 27 for b in rows: _dessine_logo(c, b["source"], M, yy - 4, 20, 13) c.setFont("Helvetica-Bold", 8.2) c.setFillColor(INK) c.drawString(M + 26, yy, _nom(b["source"])[:24]) c.setFont("Helvetica", 8.2) c.setFillColor(INK2) c.drawRightString(M + 230, yy, _fmt_i(b["n"])) c.drawRightString(M + 300, yy, _fmt_money(b["median_price"])) # mini-barre du prix médian bx0, bw_ = M + 310, 58 c.setFillColor(WHITE) c.setStrokeColor(INK) c.setLineWidth(0.5) c.rect(bx0, yy - 1, bw_, 7, stroke=1, fill=1) c.setFillColor(GREEN) c.rect(bx0, yy - 1, bw_ * min(1.0, (b["median_price"] or 0) / max_median), 7, stroke=0, fill=1) c.setFont("Helvetica", 8.2) c.setFillColor(TOMATO if b["sale_share"] > 0 else INK3) c.drawRightString(M + 420, yy, _fmt_pct(b["sale_share"] * 100)) c.setFillColor(INK2) c.drawRightString(M + 472, yy, _fmt_pct(b["avg_discount_pct"], 1)) c.drawRightString(PAGE_W - M, yy, _fmt_pct(b["max_discount_pct"], 1)) c.setStrokeColor(INK3) c.setLineWidth(0.3) c.line(M, yy - 6, PAGE_W - M, yy - 6) yy -= 17.5 r.y = yy - 8 # ======================= page 4 : matrice catégorie × bannière =========== r.saut("Rapport du marché · catégories") y = s.titre_section(r.y, "Prix médian par catégorie et bannière") mat_srcs = [b["source"] for b in rows[:9]] y = s.sous_titre(y, f"{len(mat_srcs)} plus grandes bannières — le moins cher " "de chaque rangée en vert") cat_w = 108 mcol_w = (PAGE_W - 2 * M - cat_w) / max(1, len(mat_srcs)) y_mat = y - 4 c.setFont("Courier-Bold", 7) c.setFillColor(INK3) c.drawString(M, y_mat - 12, "CATÉGORIE") for j, src in enumerate(mat_srcs): _en_tete_bannière(c, src, M + cat_w + j * mcol_w, y_mat - 2, mcol_w, box_h=22) c.setStrokeColor(INK) c.setLineWidth(1.2) c.line(M, y_mat - 36, PAGE_W - M, y_mat - 36) mat_rows = sorted( st["category_matrix"].items(), key=lambda kv: -sum(cell["n"] for cell in kv[1].values())) row_h = 15.5 yy = y_mat - 48 for cat, per in mat_rows: if yy < M + 40: break vals = [per.get(src, {}).get("median_price") for src in mat_srcs] present = [v for v in vals if v is not None] mini = min(present) if present else None maxi_v = max(present) if present else None for j, v in enumerate(vals): cx0 = M + cat_w + j * mcol_w if v is not None and mini is not None: if v == mini: c.setFillColor(GREEN_SOFT) c.rect(cx0, yy - 4.5, mcol_w, row_h - 2, stroke=0, fill=1) elif maxi_v is not None and maxi_v > mini: t = (v - mini) / (maxi_v - mini) c.saveState() c.setFillColor(TOMATO) c.setFillAlpha(0.05 + 0.18 * t) c.rect(cx0, yy - 4.5, mcol_w, row_h - 2, stroke=0, fill=1) c.restoreState() c.setFont("Helvetica-Bold", 7.4) c.setFillColor(INK) nom_cat = cat if len(cat) <= 26 else cat[:25] + "…" c.drawString(M, yy, nom_cat) for j, v in enumerate(vals): cx = M + cat_w + j * mcol_w + mcol_w - 4 if v is None: c.setFont("Helvetica", 7) c.setFillColor(INK3) c.drawRightString(cx, yy, "—") elif mini is not None and v == mini: c.setFont("Helvetica-Bold", 7.2) c.setFillColor(GREEN_DEEP) c.drawRightString(cx, yy, _fmt_money(v)) else: c.setFont("Helvetica", 7) c.setFillColor(INK2) c.drawRightString(cx, yy, _fmt_money(v)) c.setStrokeColor(INK3) c.setLineWidth(0.3) c.line(M, yy - 4.5, PAGE_W - M, yy - 4.5) yy -= row_h # légende yy -= 6 c.setFillColor(GREEN_SOFT) c.rect(M, yy - 2, 9, 7, stroke=0, fill=1) c.setFont("Helvetica", 7) c.setFillColor(INK3) c.drawString(M + 13, yy, "moins cher") c.saveState() c.setFillColor(TOMATO) c.setFillAlpha(0.22) c.rect(M + 70, yy - 2, 9, 7, stroke=0, fill=1) c.restoreState() c.drawString(M + 83, yy, "plus cher") r.y = yy - 16 # ======================= distribution des prix =========================== r.besoin(160) y = s.titre_section(r.y, "Distribution des prix") y = s.sous_titre(y, "produits actifs par palier de prix") dist = st["price_distribution"] dist_max = max((b["n"] for b in dist), default=1) or 1 yy = y - 8 for b in dist: c.setFont("Helvetica-Bold", 8.2) c.setFillColor(INK) c.drawString(M, yy, b["range"]) bx0, bw_ = M + 70, PAGE_W - 2 * M - 130 c.setFillColor(WHITE) c.setStrokeColor(INK) c.setLineWidth(0.8) c.rect(bx0, yy - 2, bw_, 10, stroke=1, fill=1) c.setFillColor(GREEN) c.rect(bx0, yy - 2, max(2, bw_ * b["n"] / dist_max), 10, stroke=0, fill=1) c.setFont("Courier-Bold", 8) c.setFillColor(INK) c.drawRightString(PAGE_W - M, yy, _fmt_i(b["n"])) yy -= 18 r.y = yy - 12 # ======================= baisses de prix (7 j) =========================== drops = st["price_drops"][:20] r.besoin(60 + max(1, len(drops)) * 15) y = s.titre_section(r.y, "Baisses de prix (7 jours)") y = s.sous_titre(y, "produits dont le prix relevé a diminué depuis la dernière synchronisation") if not drops: c.setFont("Helvetica-Oblique", 9) c.setFillColor(INK3) c.drawString(M, y - 10, "Aucune baisse détectée encore — l'historique se construit " "à chaque synchronisation.") r.y = y - 32 else: yy = y - 10 for dr in drops: r.y = yy r.besoin(20) yy = r.y _dessine_logo(c, dr["source"], M, yy - 3.5, 18, 12) c.setFont("Helvetica", 8) c.setFillColor(INK) c.drawString(M + 24, yy, dr["name"][:52] + ("…" if len(dr["name"]) > 52 else "")) # ancien prix barré -> nouveau prix ax = M + 300 c.setFont("Helvetica", 8) c.setFillColor(INK3) old_txt = _fmt_money(dr["old_price"]) c.drawString(ax, yy, old_txt) ow = c.stringWidth(old_txt, "Helvetica", 8) c.setStrokeColor(INK3) c.setLineWidth(0.8) c.line(ax - 1, yy + 2.6, ax + ow + 1, yy + 2.6) # flèche dessinée (pas de glyphe → en Helvetica) fx = ax + ow + 6 c.setStrokeColor(INK2) c.setLineWidth(1) c.line(fx, yy + 2.6, fx + 10, yy + 2.6) c.setFillColor(INK2) p = c.beginPath() p.moveTo(fx + 10, yy + 5.1) p.lineTo(fx + 14, yy + 2.6) p.lineTo(fx + 10, yy + 0.1) p.close() c.drawPath(p, stroke=0, fill=1) c.setFont("Helvetica-Bold", 8.4) c.setFillColor(GREEN_DEEP) c.drawString(fx + 19, yy, _fmt_money(dr["new_price"])) # pourcentage en tomate pct = "-" + f"{dr['drop_pct']:.1f}".replace(".", ",") + f"{NBSP}%" pw = c.stringWidth(pct, "Helvetica-Bold", 7.5) + 10 c.setFillColor(TOMATO_SOFT) c.setStrokeColor(TOMATO) c.setLineWidth(1) c.roundRect(PAGE_W - M - pw, yy - 3.5, pw, 13, 2, stroke=1, fill=1) c.setFillColor(TOMATO) c.setFont("Helvetica-Bold", 7.5) c.drawRightString(PAGE_W - M - 5, yy, pct) yy -= 16.5 r.y = yy - 10 # ======================= meilleures aubaines ============================= if deals: r.besoin(70 + len(deals) * 17) y = s.titre_section(r.y, "Meilleures aubaines du moment") y = s.sous_titre(y, "rabais relatif le plus fort, toutes bannières confondues — top 10") yy = y - 10 for d_ in deals: pct_v = 100 * (d_["regular_price"] - d_["price"]) / d_["regular_price"] _dessine_logo(c, d_["source"], M, yy - 3.5, 18, 12) c.setFont("Helvetica", 8) c.setFillColor(INK) nom_p = d_["name"] or "" c.drawString(M + 24, yy, nom_p[:46] + ("…" if len(nom_p) > 46 else "")) c.setFont("Helvetica", 7) c.setFillColor(INK3) c.drawString(M + 262, yy, _nom(d_["source"])[:16]) # prix régulier barré + prix soldé reg_txt = _fmt_money(d_["regular_price"]) c.setFont("Helvetica", 8) c.setFillColor(INK3) rx0 = M + 360 c.drawString(rx0, yy, reg_txt) rw_ = c.stringWidth(reg_txt, "Helvetica", 8) c.setStrokeColor(INK3) c.setLineWidth(0.8) c.line(rx0 - 1, yy + 2.6, rx0 + rw_ + 1, yy + 2.6) c.setFont("Helvetica-Bold", 8.6) c.setFillColor(GREEN_DEEP) c.drawString(rx0 + rw_ + 8, yy, _fmt_money(d_["price"])) pct = "-" + f"{pct_v:.0f}".replace(".", ",") + f"{NBSP}%" pw = c.stringWidth(pct, "Helvetica-Bold", 7.5) + 10 c.setFillColor(TOMATO) c.roundRect(PAGE_W - M - pw, yy - 3.5, pw, 13, 2, stroke=0, fill=1) c.setFillColor(WHITE) c.setFont("Helvetica-Bold", 7.5) c.drawRightString(PAGE_W - M - 5, yy, pct) yy -= 17 r.y = yy - 10 # ======================= journal des synchronisations ==================== if syncs: r.besoin(60 + len(syncs) * 15) y = s.titre_section(r.y, "Dernières synchronisations") y = s.sous_titre(y, "10 derniers passages des connecteurs de bannières") yy = y - 10 for lg in syncs: ok = bool(lg.get("ok")) c.setFillColor(GREEN if ok else TOMATO) c.circle(M + 4, yy + 2.6, 3.4, stroke=0, fill=1) c.setFont("Helvetica-Bold", 8.2) c.setFillColor(INK) c.drawString(M + 14, yy, _nom(lg["source"])[:20]) c.setFont("Courier", 7.5) c.setFillColor(INK3) c.drawString(M + 130, yy, time.strftime("%Y-%m-%d %H:%M", time.localtime(lg["ts"]))) c.setFont("Helvetica", 8) c.setFillColor(INK2) detail = (f"{_fmt_i(lg['found'])} produits trouvés · " f"{_fmt_i(lg['added'])} ajoutés · {_fmt_i(lg['updated'])} mis à jour · " f"{_fmt_i(lg['removed'])} retirés") c.drawString(M + 235, yy, detail) c.setFont("Helvetica-Bold", 7.5) c.setFillColor(GREEN_DEEP if ok else TOMATO) c.drawRightString(PAGE_W - M, yy, "OK" if ok else "ÉCHEC") yy -= 15.5 r.y = yy - 6 # note finale r.besoin(30) c.setFont("Helvetica", 7.5) c.setFillColor(INK3) c.drawString(M, r.y - 4, "Données recalculées à chaque synchronisation — rapport non " "contractuel, généré automatiquement à partir des prix publiés " "par les bannières.") s.pied(r.page) c.save() return buf.getvalue()