# ----------------------------------------------------------------------------- # Fabri-Ka — Agrégateur de produits québécois # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # report.py : génération du « Rapport de marché Fabri-Ka » en PDF (reportlab). # Portrait complet du commerce en ligne québécois : couverture de marque, # KPIs, prix, catégories, régions, plateformes, origine, top boutiques. # ----------------------------------------------------------------------------- from __future__ import annotations import io from datetime import date from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import cm, mm from reportlab.platypus import (BaseDocTemplate, Flowable, Frame, NextPageTemplate, PageBreak, PageTemplate, Paragraph, Spacer, Table, TableStyle) # Palette de marque CREAM = colors.HexColor("#FAF6F0") INK = colors.HexColor("#1E1B16") TERRA = colors.HexColor("#C4532E") PINE = colors.HexColor("#234438") SAND = colors.HexColor("#F1E9DD") BORDER = colors.HexColor("#E4D9C8") MUTE = colors.HexColor("#6B6257") ORIGIN_COLORS = {"A": PINE, "B": TERRA, "C": colors.HexColor("#8A6D3B"), "D": colors.HexColor("#5B5B8A"), "E": colors.HexColor("#999999")} ORIGIN_LABELS = {"A": "Fabriqué au Québec", "B": "Conçu au Québec", "C": "Détaillant québécois", "D": "Mixte", "E": "À vérifier"} def _money(v): if v is None: return "—" try: return f"{float(v):,.2f} $".replace(",", " ").replace(".", ",") except (TypeError, ValueError): return "—" def _int(v): try: return f"{int(v):,}".replace(",", " ") except (TypeError, ValueError): return "0" class Logo(Flowable): """Logo Fabri·Ka vectoriel : pastille pin + monogramme, wordmark.""" def __init__(self, size=18 * mm): super().__init__() self.size = size self.width = size self.height = size def draw(self): c = self.canv s = self.size c.setFillColor(PINE) c.roundRect(0, 0, s, s, s * 0.22, fill=1, stroke=0) c.setFillColor(CREAM) c.setFont("Helvetica-Bold", s * 0.5) c.drawCentredString(s / 2, s * 0.30, "F") c.setFillColor(TERRA) c.circle(s * 0.76, s * 0.72, s * 0.07, fill=1, stroke=0) class Bars(Flowable): """Barres horizontales (label, valeur) avec pourcentage du total.""" def __init__(self, rows, width=17 * cm, bar_color=TERRA, unit="", max_rows=None, show_pct=True): super().__init__() self.rows = rows[:max_rows] if max_rows else rows self.width = width self.bar_color = bar_color self.unit = unit self.show_pct = show_pct self.row_h = 17 self.height = self.row_h * len(self.rows) + 4 def draw(self): c = self.canv if not self.rows: return mx = max((r[1] for r in self.rows), default=1) or 1 total = sum(r[1] for r in self.rows) or 1 label_w = 4.4 * cm val_w = 3.2 * cm track = self.width - label_w - val_w y = self.height - self.row_h for label, val in self.rows: c.setFillColor(INK) c.setFont("Helvetica", 8.5) c.drawString(0, y + 5, (label or "")[:30]) c.setFillColor(SAND) c.roundRect(label_w, y + 2, track, 11, 2.5, fill=1, stroke=0) w = track * (val / mx) c.setFillColor(self.bar_color) c.roundRect(label_w, y + 2, max(w, 2), 11, 2.5, fill=1, stroke=0) c.setFillColor(INK) c.setFont("Helvetica-Bold", 8.5) txt = _int(val) + self.unit if self.show_pct: txt += f" {val / total * 100:.1f}%" c.drawRightString(self.width, y + 5, txt) y -= self.row_h class OriginBar(Flowable): """Barre segmentée proportionnelle A–E.""" def __init__(self, rows, width=16 * cm): super().__init__() self.rows = [r for r in rows if r.get("products")] self.width = width self.height = 30 def draw(self): c = self.canv total = sum(r["products"] for r in self.rows) or 1 x = 0 for r in self.rows: w = self.width * (r["products"] / total) c.setFillColor(ORIGIN_COLORS.get(r["key"], MUTE)) c.rect(x, 8, w, 16, fill=1, stroke=0) if w > 20: c.setFillColor(colors.white) c.setFont("Helvetica-Bold", 9) c.drawCentredString(x + w / 2, 13, r["key"]) x += w def _h(text, style): return Paragraph(text, style) def _cover_page(canvas, stats): """Page de couverture pleine page, sharp.""" W, H = A4 t = stats["totals"] _MOIS = ["", "janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"] d = date.today() today = f"{d.day} {_MOIS[d.month]} {d.year}" canvas.saveState() # fond crème + large bande pin en bas canvas.setFillColor(CREAM) canvas.rect(0, 0, W, H, fill=1, stroke=0) canvas.setFillColor(PINE) canvas.rect(0, 0, W, 5.5 * cm, fill=1, stroke=0) canvas.setFillColor(TERRA) canvas.rect(0, 5.5 * cm, W, 0.15 * cm, fill=1, stroke=0) # logo pastille lx, ly, ls = 2.2 * cm, H - 4 * cm, 2.4 * cm canvas.setFillColor(PINE) canvas.roundRect(lx, ly, ls, ls, ls * 0.22, fill=1, stroke=0) canvas.setFillColor(CREAM) canvas.setFont("Helvetica-Bold", ls * 0.5) canvas.drawCentredString(lx + ls / 2, ly + ls * 0.30, "F") canvas.setFillColor(TERRA) canvas.circle(lx + ls * 0.76, ly + ls * 0.72, ls * 0.07, fill=1, stroke=0) canvas.setFillColor(INK) canvas.setFont("Helvetica-Bold", 30) canvas.drawString(lx + ls + 0.5 * cm, ly + ls * 0.42, "Fabri") fw = canvas.stringWidth("Fabri", "Helvetica-Bold", 30) canvas.setFillColor(TERRA) canvas.drawString(lx + ls + 0.5 * cm + fw, ly + ls * 0.42, "·Ka") # titre canvas.setFillColor(TERRA) canvas.setFont("Helvetica-Bold", 12) canvas.drawString(2.2 * cm, H - 7.4 * cm, "RAPPORT DE MARCHÉ") canvas.setFillColor(INK) canvas.setFont("Helvetica-Bold", 40) canvas.drawString(2.2 * cm, H - 9.3 * cm, "Le commerce") canvas.drawString(2.2 * cm, H - 10.9 * cm, "en ligne québécois") canvas.setFillColor(MUTE) canvas.setFont("Helvetica", 13) canvas.drawString(2.2 * cm, H - 12 * cm, f"Portrait du marché agrégé par Fabri-Ka — {today}") # chiffres clés en vedette sur la bande pin kpis = [(_int(t.get("products")), "produits"), (_int(t.get("stores_live")), "boutiques actives"), (f"{t.get('regions') or 0}/17", "régions"), (_money(t.get("price_median")), "prix médian")] cw = W / len(kpis) for i, (val, lab) in enumerate(kpis): cx = cw * i + cw / 2 canvas.setFillColor(CREAM) canvas.setFont("Helvetica-Bold", 22) canvas.drawCentredString(cx, 3.1 * cm, str(val)) canvas.setFillColor(colors.HexColor("#B9C7BE")) canvas.setFont("Helvetica", 9) canvas.drawCentredString(cx, 2.35 * cm, lab.upper()) canvas.setFillColor(colors.HexColor("#9FB0A6")) canvas.setFont("Helvetica", 8) canvas.drawString(2.2 * cm, 1.1 * cm, "www.fabri-ka.com · Simon-Pierre Boucher · contact@spboucher.ai") canvas.restoreState() def build_report(stats: dict) -> bytes: buf = io.BytesIO() ss = getSampleStyleSheet() title = ParagraphStyle("t", parent=ss["Title"], fontName="Helvetica-Bold", fontSize=30, leading=34, textColor=INK, spaceAfter=2) sub = ParagraphStyle("s", fontName="Helvetica", fontSize=11, leading=15, textColor=MUTE) h2 = ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=15, leading=18, textColor=PINE, spaceBefore=14, spaceAfter=8) body = ParagraphStyle("b", fontName="Helvetica", fontSize=9.5, leading=14, textColor=INK) small = ParagraphStyle("sm", fontName="Helvetica", fontSize=8, leading=11, textColor=MUTE) eyebrow = ParagraphStyle("eb", fontName="Helvetica-Bold", fontSize=9, leading=12, textColor=TERRA) doc = BaseDocTemplate(buf, pagesize=A4, leftMargin=2 * cm, rightMargin=2 * cm, topMargin=1.6 * cm, bottomMargin=1.6 * cm, title="Rapport de marché Fabri-Ka", author="Simon-Pierre Boucher — Fabri-Ka") frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="main") cover_frame = Frame(0, 0, A4[0], A4[1], id="cover") def footer(canvas, d): canvas.saveState() canvas.setStrokeColor(BORDER) canvas.setLineWidth(0.5) canvas.line(doc.leftMargin, 1.3 * cm, A4[0] - doc.rightMargin, 1.3 * cm) canvas.setFont("Helvetica", 7.5) canvas.setFillColor(MUTE) canvas.drawString(doc.leftMargin, 0.95 * cm, "Fabri-Ka — www.fabri-ka.com · Simon-Pierre Boucher · contact@spboucher.ai") canvas.drawRightString(A4[0] - doc.rightMargin, 0.95 * cm, f"page {d.page - 1}") canvas.restoreState() doc.addPageTemplates([ PageTemplate(id="cover", frames=[cover_frame], onPage=lambda c, d: _cover_page(c, stats)), PageTemplate(id="main", frames=[frame], onPage=footer), ]) story = [NextPageTemplate("main"), PageBreak()] t = stats["totals"] # La page 1 est la couverture pleine page (dessinée par _cover_page). # Le contenu commence page 2. story.append(_h("APERÇU DU MARCHÉ", eyebrow)) story.append(_h("Les chiffres clés", h2)) story.append(_h( "Fabri-Ka recense les boutiques en ligne du Québec et agrège leurs " "catalogues publics (Shopify, WooCommerce, Wix, Squarespace), " "resynchronisés aux 6 heures. Ce rapport présente l'état du marché : " "volume de produits, boutiques actives, structure des prix, répartition " "par catégorie, région, plateforme et origine de fabrication (A–E).", body)) story.append(Spacer(1, 10)) # ---- KPI band kpis = [ (_int(t.get("products")), "produits agrégés"), (_int(t.get("stores_live")), "boutiques actives"), (_int(t.get("stores_registry")), "boutiques au registre"), (str(t.get("regions") or 0) + " / 17", "régions couvertes"), (_money(t.get("price_median")), "prix médian"), (_money(t.get("price_avg")), "prix moyen"), ] cells = [] row = [] for i, (val, lab) in enumerate(kpis): card = Table([[Paragraph(f'{val}', body)], [Paragraph(f'{lab.upper()}', small)]], colWidths=[5.1 * cm]) card.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, -1), colors.white), ("BOX", (0, 0), (-1, -1), 0.5, BORDER), ("TOPPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), ("LEFTPADDING", (0, 0), (-1, -1), 8)])) row.append(card) if len(row) == 3: cells.append(row); row = [] if row: cells.append(row) kpi_table = Table(cells, colWidths=[5.4 * cm] * 3, hAlign="LEFT") kpi_table.setStyle(TableStyle([("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3), ("LEFTPADDING", (0, 0), (-1, -1), 0)])) story.append(kpi_table) # ---- Structure des prix story.append(_h("Structure des prix", h2)) story.append(Bars([(b["bucket"] + " $", b["n"]) for b in stats.get("price_buckets", [])], bar_color=TERRA)) # ---- Catégories (tableau) story.append(_h("Marché par catégorie", h2)) cat_rows = [["Catégorie", "Produits", "Boutiques", "Prix médian", "Prix moyen"]] for c in stats.get("by_category", [])[:18]: cat_rows.append([c["label"], _int(c["products"]), _int(c["stores"]), _money(c.get("price_median")), _money(c.get("price_avg"))]) cat = Table(cat_rows, colWidths=[5.5 * cm, 2.4 * cm, 2.4 * cm, 3 * cm, 3 * cm]) cat.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), SAND), ("TEXTCOLOR", (0, 0), (-1, 0), INK), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 8.5), ("ALIGN", (1, 0), (-1, -1), "RIGHT"), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]), ("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)])) story.append(cat) story.append(PageBreak()) # ---- Régions story.append(_h("Marché par région administrative", h2)) story.append(Bars([(r["key"], r["products"]) for r in stats.get("by_region", [])], bar_color=PINE)) # ---- Origine story.append(_h("Origine des produits (classes A–E)", h2)) story.append(OriginBar(stats.get("by_origin", []))) story.append(Spacer(1, 4)) leg = [] for o in stats.get("by_origin", []): hexcol = "#" + ORIGIN_COLORS.get(o["key"], MUTE).hexval()[2:] leg.append(Paragraph( f' {o["key"]} ' f'{ORIGIN_LABELS.get(o["key"], o["key"])} — ' f'{_int(o.get("products"))} produits, {_int(o.get("stores"))} boutiques', small)) story += leg # ---- Plateformes story.append(_h("Plateformes ecommerce", h2)) plat_rows = [["Plateforme", "Boutiques", "Produits"]] for p in stats.get("by_platform", [])[:12]: plat_rows.append([p["key"], _int(p["stores"]), _int(p.get("products"))]) plat = Table(plat_rows, colWidths=[6 * cm, 5 * cm, 5 * cm]) plat.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), SAND), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 8.5), ("ALIGN", (1, 0), (-1, -1), "RIGHT"), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]), ("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)])) story.append(plat) # ---- Top boutiques story.append(_h("Boutiques les mieux garnies", h2)) top_rows = [["#", "Boutique", "Région", "Origine", "Produits"]] for i, s in enumerate(stats.get("top_stores", [])[:15], 1): top_rows.append([str(i), (s["name"] or s["id"])[:38], s.get("region") or "—", s.get("origin_class") or "—", _int(s.get("products"))]) top = Table(top_rows, colWidths=[1 * cm, 6.5 * cm, 4 * cm, 1.8 * cm, 2.7 * cm]) top.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), SAND), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), 8.5), ("ALIGN", (0, 0), (0, -1), "CENTER"), ("ALIGN", (3, 0), (-1, -1), "RIGHT"), ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]), ("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER), ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4)])) story.append(top) # ---- Note méthodo story.append(_h("Méthodologie & portée", h2)) cov = stats.get("coverage", {}) story.append(_h( "Les données proviennent des catalogues publics des boutiques " "(Shopify, WooCommerce, Wix, Squarespace) agrégés automatiquement et " "resynchronisés aux 6 heures. Les prix et disponibilités appartiennent " "aux marchands sources. La classification d'origine A–E s'appuie sur les " "certifications (Aliments du Québec, Les Produits du Québec), l'adhésion " "à des associations de producteurs et les mentions des sites — jamais sur " "la seule présomption qu'une entreprise est québécoise. " f"Couverture : {_int(cov.get('with_image'))} produits avec image, " f"{_int(cov.get('with_logo'))} boutiques avec logo, " f"{_int(cov.get('with_region'))} boutiques géolocalisées.", body)) doc.build(story) return buf.getvalue()