SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
10.5 KB · 281 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# pdfgen.py : rapport PDF « Le marché de l'occasion » — vue d'ensemble des5#             statistiques (héros, distributions, marques, régions, aubaines).6# -----------------------------------------------------------------------------7from __future__ import annotations89import datetime10import io1112from reportlab.lib.colors import HexColor13from reportlab.lib.pagesizes import letter14from reportlab.lib.units import mm15from reportlab.pdfgen.canvas import Canvas1617from . import marketstats1819INK = HexColor("#17181c")20INK2 = HexColor("#4c4f57")21INK3 = HexColor("#8b8e96")22ACCENT = HexColor("#ff5a2a")23ACCENT_DEEP = HexColor("#cc3f16")24PAPER = HexColor("#f4f2ec")25SURFACE = HexColor("#ffffff")26GOOD = HexColor("#1c7a4d")27LINE = HexColor("#d8d5cd")2829W, H = letter30MARGIN = 18 * mm313233def _fmt(n, suffix="") -> str:34    if n is None:35        return "—"36    return f"{round(n):,}".replace(",", " ") + suffix373839class _Doc:40    """Petit assistant de mise en page (curseur vertical + gabarits)."""4142    def __init__(self, canvas: Canvas):43        self.c = canvas44        self.y = H - MARGIN45        self.page = 146        self._chrome()4748    # -- gabarit de page -------------------------------------------------------49    def _chrome(self):50        c = self.c51        c.setFillColor(PAPER)52        c.rect(0, 0, W, H, stroke=0, fill=1)53        # bandeau encre54        c.setFillColor(INK)55        c.rect(0, H - 12 * mm, W, 12 * mm, stroke=0, fill=1)56        c.setFillColor(ACCENT)57        c.setFont("Helvetica-Bold", 11)58        c.drawString(MARGIN, H - 8 * mm, "Auto·Ka")59        c.setFillColor(HexColor("#c9cbd1"))60        c.setFont("Helvetica", 8)61        c.drawString(MARGIN + 18 * mm, H - 8 * mm,62                     "Le marché des voitures usagées au Québec — rapport d'ensemble")63        c.drawRightString(W - MARGIN, H - 8 * mm,64                          datetime.date.today().strftime("%Y-%m-%d"))65        # pied66        c.setFillColor(INK3)67        c.setFont("Helvetica", 7)68        c.drawString(MARGIN, 10 * mm,69                     "www.auto-ka.com — agrégateur indépendant, données lues "70                     "directement sur les sites des concessionnaires")71        c.drawRightString(W - MARGIN, 10 * mm, f"page {self.page}")72        self.y = H - 22 * mm7374    def new_page(self):75        self.c.showPage()76        self.page += 177        self._chrome()7879    def need(self, height: float):80        if self.y - height < 16 * mm:81            self.new_page()8283    # -- blocs -----------------------------------------------------------------84    def title(self, text: str):85        self.need(14 * mm)86        self.c.setFillColor(ACCENT_DEEP)87        self.c.setFont("Helvetica-Bold", 8)88        self.c.drawString(MARGIN, self.y, "—  " + text.upper())89        self.y -= 7 * mm9091    def hero_row(self, items: list[tuple[str, str]]):92        """Rangée de tuiles héros (valeur + étiquette)."""93        n = len(items)94        gap = 4 * mm95        w = (W - 2 * MARGIN - gap * (n - 1)) / n96        h = 20 * mm97        self.need(h + 6 * mm)98        x = MARGIN99        for value, label in items:100            self.c.setFillColor(SURFACE)101            self.c.setStrokeColor(INK)102            self.c.setLineWidth(1.2)103            self.c.roundRect(x, self.y - h, w, h, 2 * mm, stroke=1, fill=1)104            self.c.setFillColor(INK)105            self.c.setFont("Helvetica-Bold", 15)106            self.c.drawString(x + 3.5 * mm, self.y - 9 * mm, value)107            self.c.setFillColor(INK3)108            self.c.setFont("Helvetica", 6.5)109            self.c.drawString(x + 3.5 * mm, self.y - h + 3.5 * mm, label.upper())110            x += w + gap111        self.y -= h + 8 * mm112113    def bars(self, rows: list[dict], value_key="n", label_key="label",114             extra_key=None, height_per=6.2 * mm, color=ACCENT,115             value_fmt=lambda v: _fmt(v)):116        """Barres horizontales fines, étiquettes directes, coins arrondis."""117        if not rows:118            return119        total_h = height_per * len(rows)120        self.need(total_h + 4 * mm)121        max_v = max(r[value_key] or 0 for r in rows) or 1122        label_w = 42 * mm123        bar_max = W - 2 * MARGIN - label_w - 26 * mm124        for r in rows:125            v = r[value_key] or 0126            bw = max(1.2 * mm, bar_max * v / max_v)127            yb = self.y - 4.2 * mm128            self.c.setFillColor(INK2)129            self.c.setFont("Helvetica", 7.5)130            label = str(r[label_key])[:30]131            self.c.drawRightString(MARGIN + label_w - 2 * mm, yb + 0.6 * mm, label)132            self.c.setFillColor(color)133            self.c.roundRect(MARGIN + label_w, yb, bw, 3.4 * mm, 1 * mm,134                             stroke=0, fill=1)135            self.c.setFillColor(INK)136            self.c.setFont("Helvetica-Bold", 7)137            txt = value_fmt(v)138            if extra_key and r.get(extra_key) is not None:139                txt += f"   ·  moy. {_fmt(r[extra_key], ' $')}"140                self.c.setFont("Helvetica", 7)141            self.c.drawString(MARGIN + label_w + bw + 2 * mm, yb + 0.6 * mm, txt)142            self.y -= height_per143        self.y -= 5 * mm144145    def histogram(self, rows: list[dict], height=32 * mm, color=ACCENT):146        """Histogramme vertical (classes de prix/km/années)."""147        if not rows:148            return149        self.need(height + 14 * mm)150        n = len(rows)151        gap = 1.6 * mm152        bw = (W - 2 * MARGIN - gap * (n - 1)) / n153        max_v = max(r["n"] for r in rows) or 1154        base = self.y - height155        x = MARGIN156        self.c.setFont("Helvetica", 6)157        for r in rows:158            bh = max(1 * mm, height * r["n"] / max_v)159            self.c.setFillColor(color)160            self.c.roundRect(x, base, bw, bh, 1 * mm, stroke=0, fill=1)161            self.c.setFillColor(INK)162            self.c.setFont("Helvetica-Bold", 6)163            self.c.drawCentredString(x + bw / 2, base + bh + 1.4 * mm, _fmt(r["n"]))164            self.c.setFillColor(INK3)165            self.c.setFont("Helvetica", 5.8)166            self.c.drawCentredString(x + bw / 2, base - 3.2 * mm, r["label"])167            x += bw + gap168        self.y = base - 9 * mm169170    def table(self, headers: list[str], rows: list[list[str]],171              widths: list[float]):172        self.need(6 * mm * (len(rows) + 1))173        x = MARGIN174        self.c.setFont("Helvetica-Bold", 7)175        self.c.setFillColor(INK3)176        for htxt, w in zip(headers, widths):177            self.c.drawString(x, self.y, htxt.upper())178            x += w179        self.y -= 1.6 * mm180        self.c.setStrokeColor(INK)181        self.c.setLineWidth(0.8)182        self.c.line(MARGIN, self.y, W - MARGIN, self.y)183        self.y -= 4.4 * mm184        for row in rows:185            self.need(5.4 * mm)186            x = MARGIN187            self.c.setFont("Helvetica", 7.5)188            self.c.setFillColor(INK)189            for cell, w in zip(row, widths):190                self.c.drawString(x, self.y, str(cell)[:42])191                x += w192            self.c.setStrokeColor(LINE)193            self.c.setLineWidth(0.4)194            self.c.line(MARGIN, self.y - 1.6 * mm, W - MARGIN, self.y - 1.6 * mm)195            self.y -= 5.4 * mm196        self.y -= 4 * mm197198199def rapport_pdf() -> bytes:200    """Rapport PDF multi-pages : vue d'ensemble du marché de l'occasion."""201    s = marketstats.compute()202    buf = io.BytesIO()203    c = Canvas(buf, pagesize=letter)204    c.setTitle("Auto-Ka — Rapport du marché de l'occasion")205    c.setAuthor("Simon-Pierre Boucher — contact@spboucher.ai")206    doc = _Doc(c)207208    # -- entête / héros ---------------------------------------------------------209    doc.c.setFillColor(INK)210    doc.c.setFont("Helvetica-Bold", 22)211    doc.c.drawString(MARGIN, doc.y, "Le marché de l'occasion, en un coup d'œil")212    doc.y -= 6 * mm213    doc.c.setFillColor(INK2)214    doc.c.setFont("Helvetica", 9)215    doc.c.drawString(216        MARGIN, doc.y,217        f"Inventaire actif agrégé de {s['sources']} concessionnaires dans "218        f"{s['regions']} régions du Québec.")219    doc.y -= 10 * mm220221    doc.hero_row([222        (_fmt(s["total"]), "véhicules en vente"),223        (_fmt(s["avg_price"], " $"), "prix moyen"),224        (_fmt(s["median_price"], " $"), "prix médian"),225        (_fmt(s["avg_km"], " km"), "km moyen"),226    ])227    doc.hero_row([228        (f"{s['avg_year']:.0f}" if s["avg_year"] else "—", "année moyenne"),229        (_fmt(s["new_7d"]), "arrivages (7 jours)"),230        (f"{s['electrified_pct']} %", "électrifiés (VÉ + hybrides)"),231        (_fmt(s["sources"]), "concessionnaires"),232    ])233234    # -- distributions ----------------------------------------------------------235    doc.title("Distribution des prix")236    doc.histogram(s["price_hist"])237    doc.title("Distribution du kilométrage (milliers de km)")238    doc.histogram(s["km_hist"], color=HexColor("#1f6fb5"))239240    # -- marques / régions ------------------------------------------------------241    doc.new_page()242    doc.title("Top marques (inventaire et prix moyen)")243    doc.bars(s["by_make"], extra_key="avg_price")244    doc.title("Par région (inventaire et prix moyen)")245    doc.bars(s["by_region"], extra_key="avg_price",246             color=HexColor("#1f6fb5"))247248    # -- modèles ----------------------------------------------------------------249    doc.new_page()250    doc.title("Modèles les plus offerts")251    doc.table(252        ["Modèle", "En vente", "Prix moyen", "KM moyen"],253        [[r["label"], _fmt(r["n"]), _fmt(r["avg_price"], " $"),254          _fmt(r["avg_km"], " km")] for r in s["top_models"]],255        [70 * mm, 30 * mm, 38 * mm, 38 * mm])256257    doc.title("Carrosseries")258    doc.bars(s["by_body"], color=HexColor("#1c7a4d"))259    doc.title("Carburants")260    doc.bars(s["by_fuel"], color=HexColor("#b58500"))261262    # -- aubaines ---------------------------------------------------------------263    if s["price_drops"]:264        doc.new_page()265        doc.title("Baisses de prix récentes (aubaines détectées)")266        doc.table(267            ["Véhicule", "Avant", "Maintenant", "Économie", "Concessionnaire"],268            [[f"{r['title'][:34]}", _fmt(r["prev_price"], " $"),269              _fmt(r["price"], " $"),270              "-" + _fmt(r["prev_price"] - r["price"], " $"),271              f"{r['dealer_name'][:22]} ({r['city']})"]272             for r in s["price_drops"]],273            [62 * mm, 24 * mm, 26 * mm, 24 * mm, 44 * mm])274275    doc.title("Plus grands inventaires")276    doc.bars(s["top_dealers"], extra_key="avg_price",277             color=HexColor("#7d4fc9"))278279    c.save()280    return buf.getvalue()281