spb/fabri-ka Public
Agrégateur de produits québécois — www.fabri-ka.com
HTML 57.9%
Python 18.6%
TypeScript 15.6%
CSS 7.8%
1# -----------------------------------------------------------------------------2# Fabri-Ka — Agrégateur de produits québécois3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# report.py : génération du « Rapport de marché Fabri-Ka » en PDF (reportlab).5# Portrait complet du commerce en ligne québécois : couverture de marque,6# KPIs, prix, catégories, régions, plateformes, origine, top boutiques.7# -----------------------------------------------------------------------------8from __future__ import annotations910import io11from datetime import date1213from reportlab.lib import colors14from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT15from reportlab.lib.pagesizes import A416from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet17from reportlab.lib.units import cm, mm18from reportlab.platypus import (BaseDocTemplate, Flowable, Frame, NextPageTemplate,19 PageBreak, PageTemplate, Paragraph, Spacer, Table,20 TableStyle)2122# Palette de marque23CREAM = colors.HexColor("#FAF6F0")24INK = colors.HexColor("#1E1B16")25TERRA = colors.HexColor("#C4532E")26PINE = colors.HexColor("#234438")27SAND = colors.HexColor("#F1E9DD")28BORDER = colors.HexColor("#E4D9C8")29MUTE = colors.HexColor("#6B6257")3031ORIGIN_COLORS = {"A": PINE, "B": TERRA, "C": colors.HexColor("#8A6D3B"),32 "D": colors.HexColor("#5B5B8A"), "E": colors.HexColor("#999999")}33ORIGIN_LABELS = {"A": "Fabriqué au Québec", "B": "Conçu au Québec",34 "C": "Détaillant québécois", "D": "Mixte", "E": "À vérifier"}353637def _money(v):38 if v is None:39 return "—"40 try:41 return f"{float(v):,.2f} $".replace(",", " ").replace(".", ",")42 except (TypeError, ValueError):43 return "—"444546def _int(v):47 try:48 return f"{int(v):,}".replace(",", " ")49 except (TypeError, ValueError):50 return "0"515253class Logo(Flowable):54 """Logo Fabri·Ka vectoriel : pastille pin + monogramme, wordmark."""5556 def __init__(self, size=18 * mm):57 super().__init__()58 self.size = size59 self.width = size60 self.height = size6162 def draw(self):63 c = self.canv64 s = self.size65 c.setFillColor(PINE)66 c.roundRect(0, 0, s, s, s * 0.22, fill=1, stroke=0)67 c.setFillColor(CREAM)68 c.setFont("Helvetica-Bold", s * 0.5)69 c.drawCentredString(s / 2, s * 0.30, "F")70 c.setFillColor(TERRA)71 c.circle(s * 0.76, s * 0.72, s * 0.07, fill=1, stroke=0)727374class Bars(Flowable):75 """Barres horizontales (label, valeur) avec pourcentage du total."""7677 def __init__(self, rows, width=17 * cm, bar_color=TERRA, unit="", max_rows=None,78 show_pct=True):79 super().__init__()80 self.rows = rows[:max_rows] if max_rows else rows81 self.width = width82 self.bar_color = bar_color83 self.unit = unit84 self.show_pct = show_pct85 self.row_h = 1786 self.height = self.row_h * len(self.rows) + 48788 def draw(self):89 c = self.canv90 if not self.rows:91 return92 mx = max((r[1] for r in self.rows), default=1) or 193 total = sum(r[1] for r in self.rows) or 194 label_w = 4.4 * cm95 val_w = 3.2 * cm96 track = self.width - label_w - val_w97 y = self.height - self.row_h98 for label, val in self.rows:99 c.setFillColor(INK)100 c.setFont("Helvetica", 8.5)101 c.drawString(0, y + 5, (label or "")[:30])102 c.setFillColor(SAND)103 c.roundRect(label_w, y + 2, track, 11, 2.5, fill=1, stroke=0)104 w = track * (val / mx)105 c.setFillColor(self.bar_color)106 c.roundRect(label_w, y + 2, max(w, 2), 11, 2.5, fill=1, stroke=0)107 c.setFillColor(INK)108 c.setFont("Helvetica-Bold", 8.5)109 txt = _int(val) + self.unit110 if self.show_pct:111 txt += f" {val / total * 100:.1f}%"112 c.drawRightString(self.width, y + 5, txt)113 y -= self.row_h114115116class OriginBar(Flowable):117 """Barre segmentée proportionnelle A–E."""118119 def __init__(self, rows, width=16 * cm):120 super().__init__()121 self.rows = [r for r in rows if r.get("products")]122 self.width = width123 self.height = 30124125 def draw(self):126 c = self.canv127 total = sum(r["products"] for r in self.rows) or 1128 x = 0129 for r in self.rows:130 w = self.width * (r["products"] / total)131 c.setFillColor(ORIGIN_COLORS.get(r["key"], MUTE))132 c.rect(x, 8, w, 16, fill=1, stroke=0)133 if w > 20:134 c.setFillColor(colors.white)135 c.setFont("Helvetica-Bold", 9)136 c.drawCentredString(x + w / 2, 13, r["key"])137 x += w138139140def _h(text, style):141 return Paragraph(text, style)142143144def _cover_page(canvas, stats):145 """Page de couverture pleine page, sharp."""146 W, H = A4147 t = stats["totals"]148 _MOIS = ["", "janvier", "février", "mars", "avril", "mai", "juin", "juillet",149 "août", "septembre", "octobre", "novembre", "décembre"]150 d = date.today()151 today = f"{d.day} {_MOIS[d.month]} {d.year}"152153 canvas.saveState()154 # fond crème + large bande pin en bas155 canvas.setFillColor(CREAM)156 canvas.rect(0, 0, W, H, fill=1, stroke=0)157 canvas.setFillColor(PINE)158 canvas.rect(0, 0, W, 5.5 * cm, fill=1, stroke=0)159 canvas.setFillColor(TERRA)160 canvas.rect(0, 5.5 * cm, W, 0.15 * cm, fill=1, stroke=0)161162 # logo pastille163 lx, ly, ls = 2.2 * cm, H - 4 * cm, 2.4 * cm164 canvas.setFillColor(PINE)165 canvas.roundRect(lx, ly, ls, ls, ls * 0.22, fill=1, stroke=0)166 canvas.setFillColor(CREAM)167 canvas.setFont("Helvetica-Bold", ls * 0.5)168 canvas.drawCentredString(lx + ls / 2, ly + ls * 0.30, "F")169 canvas.setFillColor(TERRA)170 canvas.circle(lx + ls * 0.76, ly + ls * 0.72, ls * 0.07, fill=1, stroke=0)171 canvas.setFillColor(INK)172 canvas.setFont("Helvetica-Bold", 30)173 canvas.drawString(lx + ls + 0.5 * cm, ly + ls * 0.42, "Fabri")174 fw = canvas.stringWidth("Fabri", "Helvetica-Bold", 30)175 canvas.setFillColor(TERRA)176 canvas.drawString(lx + ls + 0.5 * cm + fw, ly + ls * 0.42, "·Ka")177178 # titre179 canvas.setFillColor(TERRA)180 canvas.setFont("Helvetica-Bold", 12)181 canvas.drawString(2.2 * cm, H - 7.4 * cm, "RAPPORT DE MARCHÉ")182 canvas.setFillColor(INK)183 canvas.setFont("Helvetica-Bold", 40)184 canvas.drawString(2.2 * cm, H - 9.3 * cm, "Le commerce")185 canvas.drawString(2.2 * cm, H - 10.9 * cm, "en ligne québécois")186 canvas.setFillColor(MUTE)187 canvas.setFont("Helvetica", 13)188 canvas.drawString(2.2 * cm, H - 12 * cm,189 f"Portrait du marché agrégé par Fabri-Ka — {today}")190191 # chiffres clés en vedette sur la bande pin192 kpis = [(_int(t.get("products")), "produits"),193 (_int(t.get("stores_live")), "boutiques actives"),194 (f"{t.get('regions') or 0}/17", "régions"),195 (_money(t.get("price_median")), "prix médian")]196 cw = W / len(kpis)197 for i, (val, lab) in enumerate(kpis):198 cx = cw * i + cw / 2199 canvas.setFillColor(CREAM)200 canvas.setFont("Helvetica-Bold", 22)201 canvas.drawCentredString(cx, 3.1 * cm, str(val))202 canvas.setFillColor(colors.HexColor("#B9C7BE"))203 canvas.setFont("Helvetica", 9)204 canvas.drawCentredString(cx, 2.35 * cm, lab.upper())205206 canvas.setFillColor(colors.HexColor("#9FB0A6"))207 canvas.setFont("Helvetica", 8)208 canvas.drawString(2.2 * cm, 1.1 * cm,209 "www.fabri-ka.com · Simon-Pierre Boucher · contact@spboucher.ai")210 canvas.restoreState()211212213def build_report(stats: dict) -> bytes:214 buf = io.BytesIO()215 ss = getSampleStyleSheet()216217 title = ParagraphStyle("t", parent=ss["Title"], fontName="Helvetica-Bold",218 fontSize=30, leading=34, textColor=INK, spaceAfter=2)219 sub = ParagraphStyle("s", fontName="Helvetica", fontSize=11, leading=15,220 textColor=MUTE)221 h2 = ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=15, leading=18,222 textColor=PINE, spaceBefore=14, spaceAfter=8)223 body = ParagraphStyle("b", fontName="Helvetica", fontSize=9.5, leading=14,224 textColor=INK)225 small = ParagraphStyle("sm", fontName="Helvetica", fontSize=8, leading=11,226 textColor=MUTE)227 eyebrow = ParagraphStyle("eb", fontName="Helvetica-Bold", fontSize=9,228 leading=12, textColor=TERRA)229230 doc = BaseDocTemplate(buf, pagesize=A4,231 leftMargin=2 * cm, rightMargin=2 * cm,232 topMargin=1.6 * cm, bottomMargin=1.6 * cm,233 title="Rapport de marché Fabri-Ka",234 author="Simon-Pierre Boucher — Fabri-Ka")235 frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="main")236 cover_frame = Frame(0, 0, A4[0], A4[1], id="cover")237238 def footer(canvas, d):239 canvas.saveState()240 canvas.setStrokeColor(BORDER)241 canvas.setLineWidth(0.5)242 canvas.line(doc.leftMargin, 1.3 * cm, A4[0] - doc.rightMargin, 1.3 * cm)243 canvas.setFont("Helvetica", 7.5)244 canvas.setFillColor(MUTE)245 canvas.drawString(doc.leftMargin, 0.95 * cm,246 "Fabri-Ka — www.fabri-ka.com · Simon-Pierre Boucher · contact@spboucher.ai")247 canvas.drawRightString(A4[0] - doc.rightMargin, 0.95 * cm,248 f"page {d.page - 1}")249 canvas.restoreState()250251 doc.addPageTemplates([252 PageTemplate(id="cover", frames=[cover_frame],253 onPage=lambda c, d: _cover_page(c, stats)),254 PageTemplate(id="main", frames=[frame], onPage=footer),255 ])256257 story = [NextPageTemplate("main"), PageBreak()]258 t = stats["totals"]259260 # La page 1 est la couverture pleine page (dessinée par _cover_page).261 # Le contenu commence page 2.262 story.append(_h("APERÇU DU MARCHÉ", eyebrow))263 story.append(_h("Les chiffres clés", h2))264 story.append(_h(265 "Fabri-Ka recense les boutiques en ligne du Québec et agrège leurs "266 "catalogues publics (Shopify, WooCommerce, Wix, Squarespace), "267 "resynchronisés aux 6 heures. Ce rapport présente l'état du marché : "268 "volume de produits, boutiques actives, structure des prix, répartition "269 "par catégorie, région, plateforme et origine de fabrication (A–E).",270 body))271 story.append(Spacer(1, 10))272273 # ---- KPI band274 kpis = [275 (_int(t.get("products")), "produits agrégés"),276 (_int(t.get("stores_live")), "boutiques actives"),277 (_int(t.get("stores_registry")), "boutiques au registre"),278 (str(t.get("regions") or 0) + " / 17", "régions couvertes"),279 (_money(t.get("price_median")), "prix médian"),280 (_money(t.get("price_avg")), "prix moyen"),281 ]282 cells = []283 row = []284 for i, (val, lab) in enumerate(kpis):285 card = Table([[Paragraph(f'<font size=15 color="#234438"><b>{val}</b></font>', body)],286 [Paragraph(f'<font size=8 color="#6B6257">{lab.upper()}</font>', small)]],287 colWidths=[5.1 * cm])288 card.setStyle(TableStyle([289 ("BACKGROUND", (0, 0), (-1, -1), colors.white),290 ("BOX", (0, 0), (-1, -1), 0.5, BORDER),291 ("TOPPADDING", (0, 0), (-1, -1), 6),292 ("BOTTOMPADDING", (0, 0), (-1, -1), 6),293 ("LEFTPADDING", (0, 0), (-1, -1), 8)]))294 row.append(card)295 if len(row) == 3:296 cells.append(row); row = []297 if row:298 cells.append(row)299 kpi_table = Table(cells, colWidths=[5.4 * cm] * 3, hAlign="LEFT")300 kpi_table.setStyle(TableStyle([("TOPPADDING", (0, 0), (-1, -1), 3),301 ("BOTTOMPADDING", (0, 0), (-1, -1), 3),302 ("LEFTPADDING", (0, 0), (-1, -1), 0)]))303 story.append(kpi_table)304305 # ---- Structure des prix306 story.append(_h("Structure des prix", h2))307 story.append(Bars([(b["bucket"] + " $", b["n"]) for b in stats.get("price_buckets", [])],308 bar_color=TERRA))309310 # ---- Catégories (tableau)311 story.append(_h("Marché par catégorie", h2))312 cat_rows = [["Catégorie", "Produits", "Boutiques", "Prix médian", "Prix moyen"]]313 for c in stats.get("by_category", [])[:18]:314 cat_rows.append([c["label"], _int(c["products"]), _int(c["stores"]),315 _money(c.get("price_median")), _money(c.get("price_avg"))])316 cat = Table(cat_rows, colWidths=[5.5 * cm, 2.4 * cm, 2.4 * cm, 3 * cm, 3 * cm])317 cat.setStyle(TableStyle([318 ("BACKGROUND", (0, 0), (-1, 0), SAND),319 ("TEXTCOLOR", (0, 0), (-1, 0), INK),320 ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),321 ("FONTSIZE", (0, 0), (-1, -1), 8.5),322 ("ALIGN", (1, 0), (-1, -1), "RIGHT"),323 ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]),324 ("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER),325 ("TOPPADDING", (0, 0), (-1, -1), 4),326 ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))327 story.append(cat)328329 story.append(PageBreak())330331 # ---- Régions332 story.append(_h("Marché par région administrative", h2))333 story.append(Bars([(r["key"], r["products"]) for r in stats.get("by_region", [])],334 bar_color=PINE))335336 # ---- Origine337 story.append(_h("Origine des produits (classes A–E)", h2))338 story.append(OriginBar(stats.get("by_origin", [])))339 story.append(Spacer(1, 4))340 leg = []341 for o in stats.get("by_origin", []):342 hexcol = "#" + ORIGIN_COLORS.get(o["key"], MUTE).hexval()[2:]343 leg.append(Paragraph(344 f'<font color="{hexcol}">■</font> <b>{o["key"]}</b> '345 f'{ORIGIN_LABELS.get(o["key"], o["key"])} — '346 f'{_int(o.get("products"))} produits, {_int(o.get("stores"))} boutiques', small))347 story += leg348349 # ---- Plateformes350 story.append(_h("Plateformes ecommerce", h2))351 plat_rows = [["Plateforme", "Boutiques", "Produits"]]352 for p in stats.get("by_platform", [])[:12]:353 plat_rows.append([p["key"], _int(p["stores"]), _int(p.get("products"))])354 plat = Table(plat_rows, colWidths=[6 * cm, 5 * cm, 5 * cm])355 plat.setStyle(TableStyle([356 ("BACKGROUND", (0, 0), (-1, 0), SAND),357 ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),358 ("FONTSIZE", (0, 0), (-1, -1), 8.5),359 ("ALIGN", (1, 0), (-1, -1), "RIGHT"),360 ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]),361 ("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER),362 ("TOPPADDING", (0, 0), (-1, -1), 4),363 ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))364 story.append(plat)365366 # ---- Top boutiques367 story.append(_h("Boutiques les mieux garnies", h2))368 top_rows = [["#", "Boutique", "Région", "Origine", "Produits"]]369 for i, s in enumerate(stats.get("top_stores", [])[:15], 1):370 top_rows.append([str(i), (s["name"] or s["id"])[:38], s.get("region") or "—",371 s.get("origin_class") or "—", _int(s.get("products"))])372 top = Table(top_rows, colWidths=[1 * cm, 6.5 * cm, 4 * cm, 1.8 * cm, 2.7 * cm])373 top.setStyle(TableStyle([374 ("BACKGROUND", (0, 0), (-1, 0), SAND),375 ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),376 ("FONTSIZE", (0, 0), (-1, -1), 8.5),377 ("ALIGN", (0, 0), (0, -1), "CENTER"),378 ("ALIGN", (3, 0), (-1, -1), "RIGHT"),379 ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]),380 ("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER),381 ("TOPPADDING", (0, 0), (-1, -1), 4),382 ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))383 story.append(top)384385 # ---- Note méthodo386 story.append(_h("Méthodologie & portée", h2))387 cov = stats.get("coverage", {})388 story.append(_h(389 "Les données proviennent des catalogues publics des boutiques "390 "(Shopify, WooCommerce, Wix, Squarespace) agrégés automatiquement et "391 "resynchronisés aux 6 heures. Les prix et disponibilités appartiennent "392 "aux marchands sources. La classification d'origine A–E s'appuie sur les "393 "certifications (Aliments du Québec, Les Produits du Québec), l'adhésion "394 "à des associations de producteurs et les mentions des sites — jamais sur "395 "la seule présomption qu'une entreprise est québécoise. "396 f"Couverture : {_int(cov.get('with_image'))} produits avec image, "397 f"{_int(cov.get('with_logo'))} boutiques avec logo, "398 f"{_int(cov.get('with_region'))} boutiques géolocalisées.", body))399400 doc.build(story)401 return buf.getvalue()402