# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/stats.py # Desc: Tableau de bord analytique — contrat commun Groupe KA v2 (ka-ui/ # stats/SPEC.md §2). Agrège la DB réelle (restaurants, menus, # item_price_log, sync_log, details RACJ/Yelp/MAPAQ) : KPI avec deltas # et sparklines, jauges de complétude, séries quotidiennes, multi- # courbes (prix moyen par ville), barres empilées (ajouts par source), # répartitions, distributions, géographie, heatmaps calendrier + # horaire, tableaux et records. AUCUNE stat inventée : une mesure # indisponible est simplement absente du JSON. # Cache mémoire 5 min par période. # ============================================================================== from __future__ import annotations import json import statistics import threading import time from collections import Counter from datetime import date, datetime, timedelta from zoneinfo import ZoneInfo from . import db TZ = ZoneInfo("America/Toronto") CACHE_TTL = 300 # ≥ 5 min (SPEC.md §2) _cache: dict[tuple, tuple[float, dict]] = {} _cache_lock = threading.Lock() # libellés FR compacts pour cuisines/types (sous-ensemble de api.ts) _CUISINE_LABELS = { "cafe-dessert": "Café & desserts", "autre": "Autre", "burgers": "Burgers", "pizza": "Pizza", "fast-food": "Restauration rapide", "poulet": "Poulet", "quebecois": "Québécois", "sushi-japonais": "Sushi & japonais", "italien": "Italien", "bbq-grillades": "BBQ & grillades", "chinois": "Chinois", "dejeuner-brunch": "Déjeuner & brunch", "mexicain": "Mexicain", "libanais-moyen-orient": "Libanais & M-O", "thai": "Thaï", "indien": "Indien", "grec": "Grec", "vietnamien": "Vietnamien", "coreen": "Coréen", "francais": "Français", "fruits-de-mer": "Fruits de mer", "vegetarien": "Végétarien", } _TYPE_LABELS = { "restaurant": "Restaurant", "fast-food": "Restauration rapide", "cafe": "Café", "bar": "Bar", "boulangerie-patisserie": "Boulangerie-pâtisserie", "casse-croute": "Casse-croûte", "traiteur": "Traiteur", "creme-glacee": "Crème glacée", } _CTX_LABELS = {"dine-in": "En salle", "takeout": "Pour emporter", "delivery": "Livraison"} _PRICE_BINS = [ ("< 5 $", 0, 5), ("5-10 $", 5, 10), ("10-15 $", 10, 15), ("15-20 $", 15, 20), ("20-25 $", 20, 25), ("25-30 $", 25, 30), ("30-40 $", 30, 40), ("40-50 $", 40, 50), ("50 $ +", 50, 1e9), ] _CAP_BINS = [ ("1-50", 1, 50), ("50-100", 50, 100), ("100-200", 100, 200), ("200-300", 200, 300), ("300-500", 300, 500), ("500 +", 500, 1e9), ] _YELP_BINS = [ ("< 3", 0, 3), ("3 à 3,5", 3, 3.5), ("3,5 à 4", 3.5, 4), ("4 à 4,5", 4, 4.5), ("4,5 à 5", 4.5, 5.01), ] PERIOD_LABELS = { "auj": "Aujourd'hui", "7j": "7 jours", "30j": "30 jours", "3m": "3 mois", "6m": "6 mois", "12m": "12 mois", "annee": "Année en cours", "tout": "Toute la période", } def _day(ts: float) -> str: return datetime.fromtimestamp(ts, TZ).strftime("%Y-%m-%d") def _epoch(d: date, end: bool = False) -> float: dt = datetime(d.year, d.month, d.day, tzinfo=TZ) if end: dt += timedelta(days=1) return dt.timestamp() def _resolve_period(con, period: str, dfrom: str | None, dto: str | None) -> tuple[date, date, str]: """(from, to, label) — bornes inclusives en dates locales.""" today = datetime.now(TZ).date() if dfrom and dto: try: f = date.fromisoformat(dfrom) t = date.fromisoformat(dto) if f <= t: return f, t, f"du {f} au {t}" except ValueError: pass days = {"7j": 7, "30j": 30, "3m": 91, "6m": 182, "12m": 365} if period == "auj": return today, today, PERIOD_LABELS["auj"] if period in days: return today - timedelta(days=days[period] - 1), today, \ PERIOD_LABELS[period] if period == "annee": return date(today.year, 1, 1), today, f"Année {today.year}" # tout : depuis la première fiche référencée row = con.execute("SELECT MIN(first_seen) m FROM restaurants").fetchone() start = date.fromtimestamp(row["m"]) if row and row["m"] else today return start, today, PERIOD_LABELS["tout"] def _delta(cur: float, prev: float) -> float | None: if not prev: return None return round(100.0 * (cur - prev) / prev, 1) def _kpi(id_, label, value, unit="", delta_pct=None, positive_is_up=True, spark=None): k = {"id": id_, "label": label, "value": value, "unit": unit, "delta_pct": delta_pct} if delta_pct is not None: up = delta_pct >= 0 if positive_is_up else delta_pct < 0 k["direction"] = "up" if up else "down" if spark and len(spark) >= 2: k["spark"] = spark return k def _spark(pts: list[dict], n: int = 30) -> list[dict]: """Sous-échantillonne une série quotidienne pour la sparkline (≤ n pts).""" if len(pts) <= n: return pts step = max(1, len(pts) // n) out = pts[::step] if out[-1] is not pts[-1]: out.append(pts[-1]) return out def _pct(part: int, total: int) -> float: return round(100.0 * part / total, 1) if total else 0.0 def _fr(n: float, dec: int = 2) -> str: return f"{n:.{dec}f}".replace(".", ",") def _daily(rows: list, f: date, t: date, cumulative: bool = False, base: int = 0) -> list[dict]: """Série quotidienne bouchée à zéro sur [f, t] à partir de {jour: n}.""" by_day = dict(rows) n_days = (t - f).days + 1 pts, acc = [], base step = max(1, n_days // 366) # plafonne le nombre de points d = f while d <= t: v = 0 for k in range(step): v += by_day.get((d + timedelta(days=k)).isoformat(), 0) acc += v pts.append({"t": d.isoformat(), "v": acc if cumulative else v}) d += timedelta(days=step) if pts and pts[-1]["t"] != t.isoformat(): pts.append({"t": t.isoformat(), "v": acc if cumulative else 0}) return pts def _iter_menu_items(con): for r in con.execute( "SELECT m.sections, r.chain, r.name FROM menus m" " JOIN restaurants r ON r.uid=m.uid" " WHERE r.active=1 AND r.dup_of IS NULL"): try: sections = json.loads(r["sections"] or "[]") except ValueError: continue for sec in sections: for it in sec.get("items") or []: yield r, it def _bin_counts(values: list[float], bins) -> list[dict]: counts = Counter() for v in values: for lbl, lo, hi in bins: if lo <= v < hi: counts[lbl] += 1 break return [{"label": lbl, "value": counts[lbl]} for lbl, _, _ in bins if counts[lbl]] def _build(period: str, dfrom: str | None, dto: str | None) -> dict: con = db.connect() try: f, t, label = _resolve_period(con, period, dfrom, dto) f_ts, t_ts = _epoch(f), _epoch(t, end=True) span = (t - f).days + 1 pf, pt = f - timedelta(days=span), f - timedelta(days=1) pf_ts, pt_ts = _epoch(pf), _epoch(pt, end=True) A = "active=1 AND dup_of IS NULL" # restos comptés partout # ------------------------------------------------------------ KPI --- total = con.execute( f"SELECT COUNT(*) n FROM restaurants WHERE {A}").fetchone()["n"] # proxy de stock par first_seen (croissance sur la période) stock_end = con.execute( f"SELECT COUNT(*) n FROM restaurants WHERE {A} AND first_seen''").fetchone()["n"] new_cur = con.execute( f"SELECT COUNT(*) n FROM restaurants WHERE {A}" " AND first_seen>=? AND first_seen=? AND first_seen=? AND updated_at=? AND updated_at=? AND ts=? AND ts'') tel, SUM(website<>'') web, SUM(hours IS NOT NULL AND hours<>'' AND hours<>'{{}}') hrs, SUM(price_range<>'') pr, SUM(images IS NOT NULL AND images<>'' AND images<>'[]') img FROM restaurants WHERE {A}""").fetchone() # ------------------------- enrichissements (details : RACJ/Yelp/MAPAQ) permis_n = 0 caps: list[float] = [] max_cap: tuple[str | None, float] = (None, 0.0) yelp_ratings: list[float] = [] best_yelp: tuple[str | None, float, int] = (None, 0.0, 0) mapaq_n = 0 for r in con.execute( f"SELECT name, city, details FROM restaurants WHERE {A}" " AND details IS NOT NULL AND details<>''"): try: det = json.loads(r["details"]) except ValueError: continue pa = det.get("permis_alcool") if pa: permis_n += 1 cap = pa.get("capacite") if isinstance(cap, (int, float)) and cap > 0: caps.append(float(cap)) if cap > max_cap[1]: max_cap = (f"{r['name']} ({r['city']})", float(cap)) y = det.get("yelp") if y and isinstance(y.get("rating"), (int, float)): yelp_ratings.append(float(y["rating"])) rc = int(y.get("review_count") or 0) if (y["rating"], rc) > (best_yelp[1], best_yelp[2]): best_yelp = (f"{r['name']} ({r['city']})", float(y["rating"]), rc) if det.get("mapaq"): mapaq_n += 1 # -------------------------------------------- séries quotidiennes --- new_by_day = [(r["d"], r["n"]) for r in con.execute( "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n" f" FROM restaurants WHERE {A} AND first_seen>=? AND first_seen=? AND m0=? AND ts=? AND updated_at=? AND ts0" " ORDER BY d", (f_ts, t_ts))] multiseries = [] if len(grid_days) >= 2: city_day: dict[str, dict[str, float]] = {} city_tot: Counter = Counter() for r in con.execute( "SELECT re.city c, date(l.ts,'unixepoch','localtime') d," " AVG(l.price) a, COUNT(*) n FROM item_price_log l" " JOIN restaurants re ON re.uid=l.uid" " WHERE l.ts>=? AND l.ts0 AND re.city<>''" " GROUP BY c, d", (f_ts, t_ts)): city_day.setdefault(r["c"], {})[r["d"]] = round(r["a"], 2) city_tot[r["c"]] += r["n"] chosen = [] for city, _n in city_tot.most_common(): if all(d in city_day[city] for d in grid_days): chosen.append(city) if len(chosen) == 4: break if len(chosen) >= 2: multiseries.append({ "id": "prix_villes", "title": "Prix moyen d'un plat relevé — grandes villes", "unit": "$", "series": [{"label": c, "points": [ {"t": d, "v": city_day[c][d]} for d in grid_days]} for c in chosen]}) # ------------------------------- empilé : éléments ajoutés / source --- src_added: dict[str, list] = {} for r in con.execute( "SELECT source s, date(ts,'unixepoch','localtime') d," " SUM(added) n FROM sync_log WHERE ts>=? AND ts max_item[1]: max_item = (f"{it.get('name')} — {r['chain'] or r['name']}", p) types = [{"label": _TYPE_LABELS.get(r["t"], r["t"] or "Autre"), "value": r["n"]} for r in con.execute( f"SELECT establishment_type t, COUNT(*) n FROM restaurants" f" WHERE {A} AND establishment_type<>'' GROUP BY t" " ORDER BY n DESC")] ctx_items = [{"label": _CTX_LABELS.get(r["c"], r["c"]), "value": r["n"]} for r in con.execute( "SELECT m.price_context c, COUNT(*) n FROM menus m" " JOIN restaurants r ON r.uid=m.uid" " WHERE r.active=1 AND r.dup_of IS NULL" " GROUP BY c ORDER BY n DESC")] gamme_items = [{"label": r["g"], "value": r["n"]} for r in con.execute( f"SELECT price_range g, COUNT(*) n FROM restaurants WHERE {A}" " AND price_range<>'' GROUP BY g ORDER BY LENGTH(g)")] breakdowns = [ {"id": "cuisines", "title": "Restos par type de cuisine (top 8)", "kind": "donut", "items": top_cuisines}, {"id": "contextes", "title": "Menus par contexte de prix", "kind": "donut", "items": ctx_items}, {"id": "types", "title": "Par type d'établissement", "kind": "bar", "items": types}, ] if gamme_items: breakdowns.append( {"id": "gammes", "title": "Restos par fourchette de prix estimée", "kind": "bar", "items": gamme_items}) # ---------------------------------------------------- distributions --- distributions = [] if all_prices: distributions.append( {"id": "prix_plats", "title": "Distribution des prix de plats", "unit": "plats", "bins": _bin_counts(all_prices, _PRICE_BINS)}) if caps: distributions.append( {"id": "capacites", "title": "Capacité des salles (permis d'alcool RACJ)", "unit": "restos", "bins": _bin_counts(caps, _CAP_BINS)}) if yelp_ratings: distributions.append( {"id": "notes_yelp", "title": "Distribution des notes Yelp", "unit": "restos", "bins": _bin_counts(yelp_ratings, _YELP_BINS)}) # -------------------------------------------------------------- géo --- geo = {"title": "Restos par région", "items": [ {"label": r["region"], "value": r["n"]} for r in con.execute( f"SELECT region, COUNT(*) n FROM restaurants WHERE {A}" " AND region<>'' GROUP BY region ORDER BY n DESC")]} # ---------------------------------------------------------- heatmap --- heat = [{"date": r["d"], "value": r["n"]} for r in con.execute( "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n" f" FROM restaurants WHERE {A} GROUP BY d ORDER BY d")] heatmap = {"title": "Nouveaux restos référencés", "cells": heat} # ------------------------------------------- heatmap horaire (7×24) --- hourly_cells = [ {"dow": r["w"], "hour": r["h"], "value": r["n"]} for r in con.execute( "SELECT (CAST(strftime('%w',ts,'unixepoch','localtime')" " AS INTEGER)+6)%7 w," " CAST(strftime('%H',ts,'unixepoch','localtime') AS INTEGER) h," " COUNT(*) n FROM item_price_log WHERE ts>=? AND ts'' GROUP BY city ORDER BY n DESC LIMIT 25")] # top chaînes : succursales, plats du menu le plus complet, prix moyen chain_rows: dict[str, dict] = {} for r in con.execute( f"SELECT chain, COUNT(*) n FROM restaurants WHERE {A}" " AND chain IS NOT NULL GROUP BY chain"): chain_rows[r["chain"]] = {"locs": r["n"], "items": 0, "prices": []} for r in con.execute( "SELECT r.chain, m.item_count, m.sections FROM menus m" " JOIN restaurants r ON r.uid=m.uid" " WHERE r.active=1 AND r.dup_of IS NULL" " AND r.chain IS NOT NULL"): cr = chain_rows.get(r["chain"]) if cr is None or (r["item_count"] or 0) <= cr["items"]: continue cr["items"] = r["item_count"] or 0 try: secs = json.loads(r["sections"] or "[]") except ValueError: continue cr["prices"] = [it["price"] for s in secs for it in s.get("items") or [] if isinstance(it.get("price"), (int, float)) and it["price"] > 0] top_chains = [] for name, cr in sorted(chain_rows.items(), key=lambda kv: -kv[1]["locs"])[:25]: avg = (f"{statistics.mean(cr['prices']):.2f} $".replace(".", ",") if cr["prices"] else "—") top_chains.append([name, cr["locs"], cr["items"] or "—", avg]) # top établissements par nombre de plats au menu seen_uids: set[str] = set() top_places = [] for r in con.execute( "SELECT r.uid, r.name, r.city, m.price_context c," " m.item_count ic, m.sections FROM menus m" " JOIN restaurants r ON r.uid=m.uid" " WHERE r.active=1 AND r.dup_of IS NULL" " ORDER BY m.item_count DESC LIMIT 60"): if r["uid"] in seen_uids: continue seen_uids.add(r["uid"]) prices = [] try: secs = json.loads(r["sections"] or "[]") prices = [it["price"] for s in secs for it in s.get("items") or [] if isinstance(it.get("price"), (int, float)) and it["price"] > 0] except ValueError: pass avg = (f"{statistics.mean(prices):.2f} $".replace(".", ",") if prices else "—") top_places.append([r["name"], r["city"] or "—", _CTX_LABELS.get(r["c"], r["c"]), r["ic"] or 0, avg]) if len(top_places) == 25: break # sources & connecteurs : couverture + dernier sync src_restos = {r["source"]: (r["n"], r["wm"]) for r in con.execute( f"SELECT source, COUNT(*) n, SUM(EXISTS (SELECT 1 FROM menus m" f" WHERE m.uid=restaurants.uid)) wm FROM restaurants WHERE {A}" " GROUP BY source")} src_last: dict[str, dict] = {} for r in con.execute( "SELECT source, ts, added, ok, message FROM sync_log" " ORDER BY ts"): src_last[r["source"]] = dict(r) src_added_period = {r["source"]: r["n"] or 0 for r in con.execute( "SELECT source, SUM(added) n FROM sync_log" " WHERE ts>=? AND ts=? AND first_seen=? AND ts dict: key = (period, dfrom or "", dto or "") now = time.time() with _cache_lock: hit = _cache.get(key) if hit and now - hit[0] < CACHE_TTL: return hit[1] data = _build(period, dfrom, dto) with _cache_lock: _cache[key] = (time.time(), data) return data