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