SPB Git

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%
16.5 KB · 402 lines python
Raw Blame History
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