SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

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 1 mo ago (Aug 25, 2026) parent 7e29e36

2 changed files +485 −2

modified louka/statsdash.py +2 −2
@@ -792,8 +792,8 @@ def _build(con, period: str, from_: str | None, to_: str | None) -> dict:
792 792 if hourly:
793 793 out["hourly"] = hourly
794 794 try:
795 − from . import statsextra
796 − pnls = statsextra.panels(con)
795 + from . import statsextra, statsfiche
796 + pnls = statsfiche.panels(con) + statsextra.panels(con)
797 797 if pnls:
798 798 out["panels"] = pnls
799 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