|
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 |
|