# ----------------------------------------------------------------------------- # Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # pdfgen.py : rapport PDF « Le marché de l'occasion » — vue d'ensemble des # statistiques (héros, distributions, marques, régions, aubaines). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import io from reportlab.lib.colors import HexColor from reportlab.lib.pagesizes import letter from reportlab.lib.units import mm from reportlab.pdfgen.canvas import Canvas from . import marketstats INK = HexColor("#17181c") INK2 = HexColor("#4c4f57") INK3 = HexColor("#8b8e96") ACCENT = HexColor("#ff5a2a") ACCENT_DEEP = HexColor("#cc3f16") PAPER = HexColor("#f4f2ec") SURFACE = HexColor("#ffffff") GOOD = HexColor("#1c7a4d") LINE = HexColor("#d8d5cd") W, H = letter MARGIN = 18 * mm def _fmt(n, suffix="") -> str: if n is None: return "—" return f"{round(n):,}".replace(",", " ") + suffix class _Doc: """Petit assistant de mise en page (curseur vertical + gabarits).""" def __init__(self, canvas: Canvas): self.c = canvas self.y = H - MARGIN self.page = 1 self._chrome() # -- gabarit de page ------------------------------------------------------- def _chrome(self): c = self.c c.setFillColor(PAPER) c.rect(0, 0, W, H, stroke=0, fill=1) # bandeau encre c.setFillColor(INK) c.rect(0, H - 12 * mm, W, 12 * mm, stroke=0, fill=1) c.setFillColor(ACCENT) c.setFont("Helvetica-Bold", 11) c.drawString(MARGIN, H - 8 * mm, "Auto·Ka") c.setFillColor(HexColor("#c9cbd1")) c.setFont("Helvetica", 8) c.drawString(MARGIN + 18 * mm, H - 8 * mm, "Le marché des voitures usagées au Québec — rapport d'ensemble") c.drawRightString(W - MARGIN, H - 8 * mm, datetime.date.today().strftime("%Y-%m-%d")) # pied c.setFillColor(INK3) c.setFont("Helvetica", 7) c.drawString(MARGIN, 10 * mm, "www.auto-ka.com — agrégateur indépendant, données lues " "directement sur les sites des concessionnaires") c.drawRightString(W - MARGIN, 10 * mm, f"page {self.page}") self.y = H - 22 * mm def new_page(self): self.c.showPage() self.page += 1 self._chrome() def need(self, height: float): if self.y - height < 16 * mm: self.new_page() # -- blocs ----------------------------------------------------------------- def title(self, text: str): self.need(14 * mm) self.c.setFillColor(ACCENT_DEEP) self.c.setFont("Helvetica-Bold", 8) self.c.drawString(MARGIN, self.y, "— " + text.upper()) self.y -= 7 * mm def hero_row(self, items: list[tuple[str, str]]): """Rangée de tuiles héros (valeur + étiquette).""" n = len(items) gap = 4 * mm w = (W - 2 * MARGIN - gap * (n - 1)) / n h = 20 * mm self.need(h + 6 * mm) x = MARGIN for value, label in items: self.c.setFillColor(SURFACE) self.c.setStrokeColor(INK) self.c.setLineWidth(1.2) self.c.roundRect(x, self.y - h, w, h, 2 * mm, stroke=1, fill=1) self.c.setFillColor(INK) self.c.setFont("Helvetica-Bold", 15) self.c.drawString(x + 3.5 * mm, self.y - 9 * mm, value) self.c.setFillColor(INK3) self.c.setFont("Helvetica", 6.5) self.c.drawString(x + 3.5 * mm, self.y - h + 3.5 * mm, label.upper()) x += w + gap self.y -= h + 8 * mm def bars(self, rows: list[dict], value_key="n", label_key="label", extra_key=None, height_per=6.2 * mm, color=ACCENT, value_fmt=lambda v: _fmt(v)): """Barres horizontales fines, étiquettes directes, coins arrondis.""" if not rows: return total_h = height_per * len(rows) self.need(total_h + 4 * mm) max_v = max(r[value_key] or 0 for r in rows) or 1 label_w = 42 * mm bar_max = W - 2 * MARGIN - label_w - 26 * mm for r in rows: v = r[value_key] or 0 bw = max(1.2 * mm, bar_max * v / max_v) yb = self.y - 4.2 * mm self.c.setFillColor(INK2) self.c.setFont("Helvetica", 7.5) label = str(r[label_key])[:30] self.c.drawRightString(MARGIN + label_w - 2 * mm, yb + 0.6 * mm, label) self.c.setFillColor(color) self.c.roundRect(MARGIN + label_w, yb, bw, 3.4 * mm, 1 * mm, stroke=0, fill=1) self.c.setFillColor(INK) self.c.setFont("Helvetica-Bold", 7) txt = value_fmt(v) if extra_key and r.get(extra_key) is not None: txt += f" · moy. {_fmt(r[extra_key], ' $')}" self.c.setFont("Helvetica", 7) self.c.drawString(MARGIN + label_w + bw + 2 * mm, yb + 0.6 * mm, txt) self.y -= height_per self.y -= 5 * mm def histogram(self, rows: list[dict], height=32 * mm, color=ACCENT): """Histogramme vertical (classes de prix/km/années).""" if not rows: return self.need(height + 14 * mm) n = len(rows) gap = 1.6 * mm bw = (W - 2 * MARGIN - gap * (n - 1)) / n max_v = max(r["n"] for r in rows) or 1 base = self.y - height x = MARGIN self.c.setFont("Helvetica", 6) for r in rows: bh = max(1 * mm, height * r["n"] / max_v) self.c.setFillColor(color) self.c.roundRect(x, base, bw, bh, 1 * mm, stroke=0, fill=1) self.c.setFillColor(INK) self.c.setFont("Helvetica-Bold", 6) self.c.drawCentredString(x + bw / 2, base + bh + 1.4 * mm, _fmt(r["n"])) self.c.setFillColor(INK3) self.c.setFont("Helvetica", 5.8) self.c.drawCentredString(x + bw / 2, base - 3.2 * mm, r["label"]) x += bw + gap self.y = base - 9 * mm def table(self, headers: list[str], rows: list[list[str]], widths: list[float]): self.need(6 * mm * (len(rows) + 1)) x = MARGIN self.c.setFont("Helvetica-Bold", 7) self.c.setFillColor(INK3) for htxt, w in zip(headers, widths): self.c.drawString(x, self.y, htxt.upper()) x += w self.y -= 1.6 * mm self.c.setStrokeColor(INK) self.c.setLineWidth(0.8) self.c.line(MARGIN, self.y, W - MARGIN, self.y) self.y -= 4.4 * mm for row in rows: self.need(5.4 * mm) x = MARGIN self.c.setFont("Helvetica", 7.5) self.c.setFillColor(INK) for cell, w in zip(row, widths): self.c.drawString(x, self.y, str(cell)[:42]) x += w self.c.setStrokeColor(LINE) self.c.setLineWidth(0.4) self.c.line(MARGIN, self.y - 1.6 * mm, W - MARGIN, self.y - 1.6 * mm) self.y -= 5.4 * mm self.y -= 4 * mm def rapport_pdf() -> bytes: """Rapport PDF multi-pages : vue d'ensemble du marché de l'occasion.""" s = marketstats.compute() buf = io.BytesIO() c = Canvas(buf, pagesize=letter) c.setTitle("Auto-Ka — Rapport du marché de l'occasion") c.setAuthor("Simon-Pierre Boucher — contact@spboucher.ai") doc = _Doc(c) # -- entête / héros --------------------------------------------------------- doc.c.setFillColor(INK) doc.c.setFont("Helvetica-Bold", 22) doc.c.drawString(MARGIN, doc.y, "Le marché de l'occasion, en un coup d'œil") doc.y -= 6 * mm doc.c.setFillColor(INK2) doc.c.setFont("Helvetica", 9) doc.c.drawString( MARGIN, doc.y, f"Inventaire actif agrégé de {s['sources']} concessionnaires dans " f"{s['regions']} régions du Québec.") doc.y -= 10 * mm doc.hero_row([ (_fmt(s["total"]), "véhicules en vente"), (_fmt(s["avg_price"], " $"), "prix moyen"), (_fmt(s["median_price"], " $"), "prix médian"), (_fmt(s["avg_km"], " km"), "km moyen"), ]) doc.hero_row([ (f"{s['avg_year']:.0f}" if s["avg_year"] else "—", "année moyenne"), (_fmt(s["new_7d"]), "arrivages (7 jours)"), (f"{s['electrified_pct']} %", "électrifiés (VÉ + hybrides)"), (_fmt(s["sources"]), "concessionnaires"), ]) # -- distributions ---------------------------------------------------------- doc.title("Distribution des prix") doc.histogram(s["price_hist"]) doc.title("Distribution du kilométrage (milliers de km)") doc.histogram(s["km_hist"], color=HexColor("#1f6fb5")) # -- marques / régions ------------------------------------------------------ doc.new_page() doc.title("Top marques (inventaire et prix moyen)") doc.bars(s["by_make"], extra_key="avg_price") doc.title("Par région (inventaire et prix moyen)") doc.bars(s["by_region"], extra_key="avg_price", color=HexColor("#1f6fb5")) # -- modèles ---------------------------------------------------------------- doc.new_page() doc.title("Modèles les plus offerts") doc.table( ["Modèle", "En vente", "Prix moyen", "KM moyen"], [[r["label"], _fmt(r["n"]), _fmt(r["avg_price"], " $"), _fmt(r["avg_km"], " km")] for r in s["top_models"]], [70 * mm, 30 * mm, 38 * mm, 38 * mm]) doc.title("Carrosseries") doc.bars(s["by_body"], color=HexColor("#1c7a4d")) doc.title("Carburants") doc.bars(s["by_fuel"], color=HexColor("#b58500")) # -- aubaines --------------------------------------------------------------- if s["price_drops"]: doc.new_page() doc.title("Baisses de prix récentes (aubaines détectées)") doc.table( ["Véhicule", "Avant", "Maintenant", "Économie", "Concessionnaire"], [[f"{r['title'][:34]}", _fmt(r["prev_price"], " $"), _fmt(r["price"], " $"), "-" + _fmt(r["prev_price"] - r["price"], " $"), f"{r['dealer_name'][:22]} ({r['city']})"] for r in s["price_drops"]], [62 * mm, 24 * mm, 26 * mm, 24 * mm, 44 * mm]) doc.title("Plus grands inventaires") doc.bars(s["top_dealers"], extra_key="avg_price", color=HexColor("#7d4fc9")) c.save() return buf.getvalue()