SPB Git

Repository Public

Commit

Stats : panneaux « enrichissements de la fiche » (KA Scores, juste valeur, historique des prix, court terme, gestionnaires, annuaire déménageurs)

- louka/statsfiche.py : 7 panneaux calculés sur les données réelles des fiches
  (couverture des enrichissements + couches branchées, KA Scores par pilier et
  par ville, verdicts de juste valeur + écarts, baisses/hausses de loyer +
  événements de vie, court terme CITQ/notes/prix par région, réputation des
  gestionnaires — masqué tant que <10 fiches notées, annuaire déménageurs)
- statsdash.py : fusion statsfiche.panels() + statsextra.panels()
- rendu 100 % générique par le kit stats existant (aucun changement frontend)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed · Aug 25, 2026 parent 7e29e36
2 changed files +485 −2 Browse files
modified louka/statsdash.py +2 −2
@@ -792,8 +792,8 @@ def _build(con, period: str, from_: str | None, to_: str | None) -> dict:
792 if hourly: 792 if hourly:
793 out["hourly"] = hourly 793 out["hourly"] = hourly
794 try: 794 try:
795− from . import statsextra 795+ from . import statsextra, statsfiche
796− pnls = statsextra.panels(con) 796+ pnls = statsfiche.panels(con) + statsextra.panels(con)
797 if pnls: 797 if pnls:
798 out["panels"] = pnls 798 out["panels"] = pnls
799 except Exception: 799 except Exception:
added louka/statsfiche.py +483 −0
@@ -0,0 +1,483 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# statsfiche.py : panneaux « enrichissements de la fiche » de l'onglet
5+# Statistiques — tout ce que Lou-Ka ajoute par-dessus l'annonce brute :
6+# · couverture des enrichissements (géocodage, quartier, KA Scores,
7+# juste valeur, immeuble, photos) + couches de données branchées ;
8+# · KA Scores (piliers, distribution, villes) ;
9+# · Juste valeur (verdicts, écarts, villes surchauffées) ;
10+# · Historique des prix (baisses/hausses) + vie des annonces ;
11+# · Court terme (chalets et hébergements, CITQ, notes) ;
12+# · Gestionnaires (fiches Google, notes) — masqué tant que < 10 notés ;
13+# · Annuaire des déménageurs du Québec.
14+# Même contrat de rendu que statsextra.py (kpis/breakdowns/distributions/
15+# tables, rendu générique du kit stats) — un panneau absent si sa source
16+# ne répond pas. Cache 30 min.
17+# -----------------------------------------------------------------------------
18+from __future__ import annotations
19+
20+import json
21+import sqlite3
22+import time
23+from pathlib import Path
24+from statistics import median
25+
26+ROOT = Path(__file__).resolve().parent.parent
27+DATA = ROOT / "data"
28+_CACHE: dict[str, tuple[float, list]] = {}
29+_TTL = 1800
30+
31+ACTIVE = "active=1 AND published=1 AND dup_of IS NULL"
32+
33+
34+def _ro(name: str) -> sqlite3.Connection | None:
35+ p = DATA / name
36+ if not p.exists():
37+ return None
38+ con = sqlite3.connect(f"file:{p}?mode=ro", uri=True)
39+ con.row_factory = sqlite3.Row
40+ return con
41+
42+
43+def _n1(con, sql: str, args: tuple = ()) -> int:
44+ return con.execute(sql, args).fetchone()[0] or 0
45+
46+
47+def _fr(n: float) -> str:
48+ return f"{round(n):,}".replace(",", " ")
49+
50+
51+def _pctof(part: int, tot: int) -> float:
52+ return round(100.0 * part / tot, 1) if tot else 0.0
53+
54+
55+# --- Panneau : couverture des enrichissements ---------------------------------
56+def _panel_enrichissement(con) -> dict | None:
57+ tot = _n1(con, f"SELECT COUNT(*) FROM listings WHERE {ACTIVE}")
58+ if tot < 100:
59+ return None
60+ geo = _n1(con, f"SELECT COUNT(*) FROM listings WHERE {ACTIVE} "
61+ "AND lat IS NOT NULL AND lng IS NOT NULL")
62+ quart = _n1(con, f"SELECT COUNT(*) FROM listings WHERE {ACTIVE} "
63+ "AND dauid IS NOT NULL AND dauid<>''")
64+ ks = _n1(con, "SELECT COUNT(*) FROM listings l JOIN kascores k"
65+ " ON l.coord_key=k.coord_key WHERE l.active=1"
66+ " AND l.published=1 AND l.dup_of IS NULL")
67+ fv = _n1(con, "SELECT COUNT(*) FROM fairvalue f JOIN listings l ON f.uid=l.uid "
68+ "WHERE l.active=1 AND l.published=1 AND l.dup_of IS NULL")
69+ bld = _n1(con, f"SELECT COUNT(*) FROM listings WHERE {ACTIVE} "
70+ "AND building_key IS NOT NULL AND building_key<>''")
71+ imgok = _n1(con, f"SELECT COUNT(*) FROM listings WHERE {ACTIVE} AND images_ok=1")
72+ compl = con.execute(
73+ f"SELECT ROUND(AVG(completeness),1) FROM listings WHERE {ACTIVE}"
74+ ).fetchone()[0]
75+
76+ kpis = [
77+ {"id": "enr_tot", "label": "Fiches actives publiées", "value": tot},
78+ {"id": "enr_compl", "label": "Complétude moyenne", "value": compl, "unit": "%"},
79+ {"id": "enr_bld", "label": "Immeubles au passeport",
80+ "value": _n1(con, "SELECT COUNT(*) FROM buildings")},
81+ {"id": "enr_img", "label": "Photos vérifiées (cumul)",
82+ "value": _n1(con, "SELECT COUNT(*) FROM image_checks")},
83+ ]
84+ bars = [
85+ {"label": "Géolocalisation", "value": _pctof(geo, tot)},
86+ {"label": "Quartier (recensement)", "value": _pctof(quart, tot)},
87+ {"label": "KA Scores", "value": _pctof(ks, tot)},
88+ {"label": "Juste valeur", "value": _pctof(fv, tot)},
89+ {"label": "Immeuble rattaché", "value": _pctof(bld, tot)},
90+ {"label": "Photos auditées OK", "value": _pctof(imgok, tot)},
91+ ]
92+
93+ # couches de données branchées sur la fiche (chaque ligne est optionnelle)
94+ layers: list[list] = []
95+
96+ def layer(nom: str, count_fn, desc: str) -> None:
97+ try:
98+ n = count_fn()
99+ if n:
100+ layers.append([nom, _fr(n), desc])
101+ except Exception:
102+ pass
103+
104+ layer("KA Scores", lambda: _n1(con, "SELECT COUNT(*) FROM kascores"),
105+ "emplacements notés (marche, transit, vélo, calme, services)")
106+ layer("Juste valeur", lambda: _n1(con, "SELECT COUNT(*) FROM fairvalue"),
107+ "évaluations de loyer (modèle comparables Lou-Ka)")
108+ layer("Historique des prix", lambda: _n1(con, "SELECT COUNT(*) FROM price_log"),
109+ "points de prix consignés depuis la première capture")
110+ layer("Immeubles", lambda: _n1(con, "SELECT COUNT(*) FROM buildings"),
111+ "passeports d'immeuble (historique par adresse)")
112+
113+ def _side(db: str, sql: str) -> int:
114+ c = _ro(db)
115+ if c is None:
116+ return 0
117+ try:
118+ return c.execute(sql).fetchone()[0] or 0
119+ finally:
120+ c.close()
121+
122+ layer("Qualité de l'air", lambda: _side("air.db", "SELECT COUNT(DISTINCT station) FROM air_stats"),
123+ "stations de mesure (RSQAQ) rattachées aux fiches")
124+ layer("Zones inondables", lambda: _side("inondation.db", "SELECT COUNT(*) FROM zi"),
125+ "polygones officiels vérifiés au survol de chaque fiche")
126+ layer("Commerces à proximité", lambda: _side("commerces.db", "SELECT COUNT(*) FROM commerces_cache"),
127+ "cellules de commerces essentiels en cache")
128+ layer("Hydro-Québec", lambda: _side("hydro.db", "SELECT COUNT(*) FROM hydro_cache"),
129+ "estimations de coût d'électricité par adresse")
130+ layer("Dossiers TAL", lambda: _side("tal.db", "SELECT COUNT(*) FROM tal_lookup"),
131+ "adresses vérifiées au Tribunal administratif du logement")
132+ layer("Registre des loyers", lambda: _side("rdl.db", "SELECT COUNT(*) FROM rdl_housings"),
133+ "loyers réellement déclarés (Vivre en ville)")
134+
135+ table = {"id": "enr_couches",
136+ "title": "Couches de données branchées sur chaque fiche",
137+ "columns": ["Couche", "Volume", "Description"],
138+ "rows": layers}
139+
140+ return {
141+ "id": "enrichissement",
142+ "title": "Enrichissement des fiches — ce que Lou-Ka ajoute à l'annonce",
143+ "subtitle": "Chaque annonce agrégée est enrichie automatiquement : "
144+ "géocodage, quartier de recensement, KA Scores, juste "
145+ "valeur, passeport d'immeuble, audit des photos et couches "
146+ "du territoire.",
147+ "kpis": [k for k in kpis if k.get("value") is not None],
148+ "breakdowns": [{"id": "enr_couv",
149+ "title": "Couverture des enrichissements (% des fiches actives)",
150+ "kind": "bar", "items": bars}],
151+ "tables": [table] if layers else [],
152+ }
153+
154+
155+# --- Panneau : KA Scores -------------------------------------------------------
156+def _panel_kascores(con) -> dict | None:
157+ rows = con.execute(
158+ "SELECT k.walk, k.transit, k.bike, k.calme, k.services, k.global, l.city"
159+ " FROM listings l JOIN kascores k ON l.coord_key=k.coord_key"
160+ " WHERE l.active=1 AND l.published=1 AND l.dup_of IS NULL"
161+ " AND k.global IS NOT NULL").fetchall()
162+ if len(rows) < 100:
163+ return None
164+ glob = [r["global"] for r in rows]
165+ kpis = [
166+ {"id": "ks_n", "label": "Fiches avec KA Scores", "value": len(rows)},
167+ {"id": "ks_med", "label": "Score global médian",
168+ "value": round(median(glob), 1), "unit": "/100"},
169+ {"id": "ks_70", "label": "Score ≥ 70 (excellents secteurs)",
170+ "value": _pctof(sum(1 for g in glob if g >= 70), len(glob)), "unit": "%"},
171+ {"id": "ks_loc", "label": "Emplacements notés (cumul)",
172+ "value": _n1(con, "SELECT COUNT(*) FROM kascores")},
173+ ]
174+ piliers = [("walk", "Marche"), ("transit", "Transport"), ("bike", "Vélo"),
175+ ("calme", "Calme"), ("services", "Services")]
176+ bars = []
177+ for col, label in piliers:
178+ vals = [r[col] for r in rows if r[col] is not None]
179+ if vals:
180+ bars.append({"label": label, "value": round(median(vals), 1)})
181+
182+ bins = []
183+ for lo in range(0, 100, 10):
184+ n = sum(1 for g in glob if lo <= g < lo + 10 or (lo == 90 and g == 100))
185+ bins.append({"label": str(lo), "value": n})
186+
187+ byc: dict[str, list[float]] = {}
188+ for r in rows:
189+ if r["city"]:
190+ byc.setdefault(r["city"], []).append(r["global"])
191+ top = sorted(((c, v) for c, v in byc.items() if len(v) >= 30),
192+ key=lambda kv: -median(kv[1]))[:12]
193+ table = {"id": "ks_villes",
194+ "title": "Meilleurs scores globaux par ville (min. 30 fiches)",
195+ "columns": ["Ville", "Fiches notées", "Score global médian"],
196+ "rows": [[c, len(v), f"{median(v):.1f}"] for c, v in top]}
197+
198+ return {
199+ "id": "kascores",
200+ "title": "KA Scores — la qualité du secteur, chiffrée",
201+ "subtitle": "Cinq piliers calculés sur données ouvertes (OSM, GTFS…) "
202+ "pour chaque emplacement : marche, transport, vélo, calme "
203+ "et services.",
204+ "kpis": kpis,
205+ "breakdowns": [{"id": "ks_piliers",
206+ "title": "Score médian par pilier (fiches actives)",
207+ "kind": "bar", "items": bars}] if bars else [],
208+ "distributions": [{"id": "ks_hist",
209+ "title": "Distribution du score global",
210+ "unit": "pts", "bins": bins}],
211+ "tables": [table] if top else [],
212+ }
213+
214+
215+# --- Panneau : juste valeur ----------------------------------------------------
216+def _panel_fairvalue(con) -> dict | None:
217+ rows = con.execute(
218+ "SELECT f.deviation, f.verdict, l.city FROM fairvalue f"
219+ " JOIN listings l ON f.uid=l.uid"
220+ " WHERE l.active=1 AND l.published=1 AND l.dup_of IS NULL"
221+ " AND f.deviation IS NOT NULL").fetchall()
222+ if len(rows) < 100:
223+ return None
224+ devs = [r["deviation"] for r in rows]
225+ # heuristique d'unité : fraction (0.08) vs pourcentage (8.0)
226+ scale = 100.0 if median(abs(d) for d in devs) < 1.5 else 1.0
227+ devs_pct = [d * scale for d in devs]
228+
229+ labels = {"marche": "Prix du marché", "sous": "Sous le marché",
230+ "sur": "Au-dessus du marché"}
231+ counts: dict[str, int] = {}
232+ for r in rows:
233+ if r["verdict"]:
234+ counts[labels.get(r["verdict"], r["verdict"])] = \
235+ counts.get(labels.get(r["verdict"], r["verdict"]), 0) + 1
236+
237+ kpis = [
238+ {"id": "fv_n", "label": "Loyers évalués (fiches actives)", "value": len(rows)},
239+ {"id": "fv_med", "label": "Écart médian au loyer estimé",
240+ "value": round(median(devs_pct), 1), "unit": "%"},
241+ {"id": "fv_sur", "label": "Au-dessus du marché",
242+ "value": _pctof(counts.get("Au-dessus du marché", 0), len(rows)), "unit": "%"},
243+ {"id": "fv_sous", "label": "Sous le marché (aubaines)",
244+ "value": _pctof(counts.get("Sous le marché", 0), len(rows)), "unit": "%"},
245+ ]
246+
247+ bins = []
248+ lo = -40
249+ while lo < 60:
250+ n = sum(1 for d in devs_pct if lo <= d < lo + 10)
251+ bins.append({"label": f"{lo}", "value": n})
252+ lo += 10
253+
254+ byc: dict[str, list[float]] = {}
255+ for r, d in zip(rows, devs_pct):
256+ if r["city"]:
257+ byc.setdefault(r["city"], []).append(d)
258+ top = sorted(((c, v) for c, v in byc.items() if len(v) >= 30),
259+ key=lambda kv: -median(kv[1]))[:12]
260+ table = {"id": "fv_villes",
261+ "title": "Villes où les loyers affichés dépassent le plus la juste "
262+ "valeur (min. 30 fiches)",
263+ "columns": ["Ville", "Fiches évaluées", "Écart médian"],
264+ "rows": [[c, len(v), f"{median(v):+.1f} %"] for c, v in top]}
265+
266+ return {
267+ "id": "juste_valeur",
268+ "title": "Juste valeur — le loyer affiché est-il le bon prix ?",
269+ "subtitle": "Chaque fiche est comparée aux logements semblables du "
270+ "même secteur (modèle de comparables Lou-Ka).",
271+ "kpis": kpis,
272+ "breakdowns": [{"id": "fv_verdicts", "title": "Verdicts de juste valeur",
273+ "kind": "donut",
274+ "items": [{"label": k, "value": v}
275+ for k, v in sorted(counts.items(),
276+ key=lambda kv: -kv[1])]}]
277+ if counts else [],
278+ "distributions": [{"id": "fv_hist",
279+ "title": "Distribution des écarts au loyer estimé (%)",
280+ "unit": "%", "bins": bins}],
281+ "tables": [table] if top else [],
282+ }
283+
284+
285+# --- Panneau : historique des prix & vie des annonces --------------------------
286+def _panel_historique(con) -> dict | None:
287+ rows = con.execute(
288+ "SELECT uid, ts, price FROM price_log ORDER BY uid, ts").fetchall()
289+ if len(rows) < 200:
290+ return None
291+ npts = len(rows)
292+ drops: list[float] = []
293+ hikes: list[float] = []
294+ changed: set[str] = set()
295+ prev_uid, prev_price = None, None
296+ for r in rows:
297+ if r["uid"] == prev_uid and prev_price and r["price"] and r["price"] != prev_price:
298+ pct = (r["price"] - prev_price) / prev_price * 100.0
299+ if -60.0 <= pct <= 120.0:
300+ (drops if pct < 0 else hikes).append(pct)
301+ changed.add(r["uid"])
302+ prev_uid, prev_price = r["uid"], r["price"]
303+
304+ kpis = [
305+ {"id": "px_pts", "label": "Points de prix consignés", "value": npts},
306+ {"id": "px_chg", "label": "Annonces avec changement de prix",
307+ "value": len(changed)},
308+ {"id": "px_drop", "label": "Baisses de loyer détectées", "value": len(drops)},
309+ {"id": "px_dmed", "label": "Baisse médiane",
310+ "value": round(median(drops), 1) if drops else None, "unit": "%"},
311+ ]
312+ donut = [{"label": "Baisses", "value": len(drops)},
313+ {"label": "Hausses", "value": len(hikes)}]
314+
315+ ev_labels = {"disparition": "Disparition", "reapparition": "Réapparition",
316+ "photos": "Photos modifiées", "description": "Description modifiée",
317+ "dispo": "Disponibilité modifiée", "inclusions": "Inclusions modifiées",
318+ "superficie": "Superficie modifiée"}
319+ evs = [{"label": ev_labels.get(r["event"], r["event"]), "value": r["n"]}
320+ for r in con.execute(
321+ "SELECT event, COUNT(*) n FROM listing_events GROUP BY event"
322+ " ORDER BY n DESC")]
323+
324+ return {
325+ "id": "historique_prix",
326+ "title": "Historique des prix — chaque loyer est suivi dans le temps",
327+ "subtitle": "Lou-Ka consigne le loyer de chaque annonce à chaque "
328+ "synchronisation : baisses, hausses et événements de vie "
329+ "de l'annonce apparaissent sur la fiche.",
330+ "kpis": [k for k in kpis if k.get("value") is not None],
331+ "breakdowns": ([{"id": "px_sens", "title": "Changements de loyer détectés",
332+ "kind": "donut", "items": donut}]
333+ + ([{"id": "px_events",
334+ "title": "Événements de vie des annonces (photos, "
335+ "description, disponibilité…)",
336+ "kind": "bar", "items": evs}] if evs else [])),
337+ }
338+
339+
340+# --- Panneau : court terme ------------------------------------------------------
341+def _panel_court_terme() -> dict | None:
342+ con = _ro("louka_ct.db")
343+ if con is None:
344+ return None
345+ try:
346+ tot, nsrc = con.execute(
347+ "SELECT COUNT(*), COUNT(DISTINCT source) FROM st_listings"
348+ " WHERE active=1").fetchone()
349+ if not tot or tot < 100:
350+ return None
351+ prices = [r[0] for r in con.execute(
352+ "SELECT price_night FROM st_listings WHERE active=1"
353+ " AND price_night > 20 AND price_night < 10000")]
354+ citq = con.execute("SELECT COUNT(*) FROM st_listings WHERE active=1"
355+ " AND citq IS NOT NULL AND citq<>''").fetchone()[0]
356+ rated = [r[0] for r in con.execute(
357+ "SELECT rating FROM st_listings WHERE active=1 AND rating IS NOT NULL")]
358+ byreg = {}
359+ for r in con.execute(
360+ "SELECT region, price_night FROM st_listings WHERE active=1"
361+ " AND region IS NOT NULL AND region<>'' AND price_night > 20"
362+ " AND price_night < 10000"):
363+ byreg.setdefault(r[0], []).append(r[1])
364+ finally:
365+ con.close()
366+
367+ bars = sorted(([{"label": k, "value": round(median(v))}
368+ for k, v in byreg.items() if len(v) >= 100]),
369+ key=lambda x: -x["value"])[:12]
370+ kpis = [
371+ {"id": "ct_n", "label": "Hébergements court terme actifs", "value": tot},
372+ {"id": "ct_src", "label": "Plateformes agrégées", "value": nsrc},
373+ {"id": "ct_px", "label": "Prix médian par nuit",
374+ "value": round(median(prices)) if prices else None, "unit": "$"},
375+ {"id": "ct_citq", "label": "Avec n° d'enregistrement CITQ",
376+ "value": _pctof(citq, tot), "unit": "%"},
377+ {"id": "ct_note", "label": "Note médiane des voyageurs",
378+ "value": round(median(rated), 2) if len(rated) >= 50 else None, "unit": "/5"},
379+ ]
380+ return {
381+ "id": "court_terme",
382+ "title": "Court terme — chalets et hébergements à la nuit",
383+ "subtitle": "La section Court terme agrège les plateformes de location "
384+ "de chalets et d'hébergements partout au Québec.",
385+ "kpis": [k for k in kpis if k.get("value") is not None],
386+ "breakdowns": [{"id": "ct_reg",
387+ "title": "Prix médian par nuit selon la région "
388+ "(min. 100 annonces)",
389+ "kind": "bar", "items": bars}] if bars else [],
390+ }
391+
392+
393+# --- Panneau : gestionnaires (masqué tant que < 10 fiches Google notées) --------
394+def _panel_gestionnaires(con) -> dict | None:
395+ rows = con.execute(
396+ "SELECT name, gmaps_rating r, gmaps_reviews nrev FROM managers"
397+ " WHERE gmaps_rating IS NOT NULL").fetchall()
398+ if len(rows) < 10:
399+ return None
400+ notes = [r["r"] for r in rows]
401+ kpis = [
402+ {"id": "mg_n", "label": "Gestionnaires répertoriés",
403+ "value": _n1(con, "SELECT COUNT(*) FROM managers")},
404+ {"id": "mg_fiche", "label": "Avec fiche Google notée", "value": len(rows)},
405+ {"id": "mg_med", "label": "Note Google médiane",
406+ "value": round(median(notes), 2), "unit": "/5"},
407+ {"id": "mg_avis", "label": "Avis cumulés",
408+ "value": sum(r["nrev"] or 0 for r in rows)},
409+ ]
410+ top = sorted((r for r in rows if (r["nrev"] or 0) >= 20),
411+ key=lambda r: -r["r"])[:12]
412+ table = {"id": "mg_top",
413+ "title": "Gestionnaires les mieux notés (min. 20 avis)",
414+ "columns": ["Gestionnaire", "Note Google", "Avis"],
415+ "rows": [[r["name"], f"{r['r']:.1f}", r["nrev"]] for r in top]}
416+ return {
417+ "id": "gestionnaires",
418+ "title": "Gestionnaires immobiliers — réputation Google",
419+ "subtitle": "Chaque gestionnaire agrégé est rapproché de sa fiche "
420+ "Google Maps : note et avis apparaissent sur ses annonces.",
421+ "kpis": kpis,
422+ "tables": [table] if top else [],
423+ }
424+
425+
426+# --- Panneau : annuaire des déménageurs -----------------------------------------
427+def _panel_demenageurs() -> dict | None:
428+ p = DATA / "demenageurs.json"
429+ if not p.exists():
430+ return None
431+ doc = json.loads(p.read_text(encoding="utf-8"))
432+ movers = doc.get("movers", [])
433+ if len(movers) < 50:
434+ return None
435+ rated = [m["rating"] for m in movers if m.get("rating")]
436+ byreg: dict[str, int] = {}
437+ for m in movers:
438+ byreg[m["region"]] = byreg.get(m["region"], 0) + 1
439+ bars = sorted(({"label": k, "value": v} for k, v in byreg.items()),
440+ key=lambda x: -x["value"])[:12]
441+ kpis = [
442+ {"id": "dem_n", "label": "Déménageurs répertoriés", "value": len(movers)},
443+ {"id": "dem_reg", "label": "Régions couvertes", "value": len(byreg)},
444+ {"id": "dem_web", "label": "Avec site web",
445+ "value": _pctof(sum(1 for m in movers if m.get("website")), len(movers)),
446+ "unit": "%"},
447+ {"id": "dem_note", "label": "Note Google médiane",
448+ "value": round(median(rated), 1) if len(rated) >= 30 else None,
449+ "unit": "/5"},
450+ ]
451+ return {
452+ "id": "annuaire_demenageurs",
453+ "title": "Annuaire des déménageurs du Québec",
454+ "subtitle": "L'annuaire complet des entreprises de déménagement de la "
455+ "province, présenté sur /demenageurs.",
456+ "kpis": [k for k in kpis if k.get("value") is not None],
457+ "breakdowns": [{"id": "dem_reg_bar",
458+ "title": "Déménageurs par région (top 12)",
459+ "kind": "bar", "items": bars}],
460+ }
461+
462+
463+def panels(con: sqlite3.Connection) -> list[dict]:
464+ """Panneaux « enrichissements de la fiche » (cache 30 min)."""
465+ hit = _CACHE.get("panels")
466+ if hit and time.time() - hit[0] < _TTL:
467+ return hit[1]
468+ out = []
469+ for fn in (lambda: _panel_enrichissement(con),
470+ lambda: _panel_kascores(con),
471+ lambda: _panel_fairvalue(con),
472+ lambda: _panel_historique(con),
473+ _panel_court_terme,
474+ lambda: _panel_gestionnaires(con),
475+ _panel_demenageurs):
476+ try:
477+ p = fn()
478+ if p and (p.get("kpis") or p.get("tables") or p.get("breakdowns")):
479+ out.append(p)
480+ except Exception:
481+ continue
482+ _CACHE["panels"] = (time.time(), out)
483+ return out
484
Menu

Theme