SPB Git

spb/lou-ka

Public

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

feat(fiche): dossier d'intelligence locative — passe sharp desktop/mobile + PDF complet

CSS : pastilles de statut affûtées, score hiver et note Google en tuiles
à ombre dure, timeline encre, media queries 640px (tableau coût réel,
dates empilées, barres d'avis compactes). PDF fiche : cinq nouvelles
sections fidèles au web — coût réel ligne par ligne (statuts étiquetés),
historique Lou-Ka + vies antérieures, passeport de l'immeuble (tuiles
KPI, rotation, pression), gestionnaire (note Google, distribution des
avis, plaintes, confiance d'association), score hiver.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 24, 2026) parent a58cabd Browse files

2 changed files +375 −9

modified frontend/src/styles.css +38 −7
@@ -2124,9 +2124,10 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2124 display: inline-block; 2124 display: inline-block;
2125 font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; 2125 font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
2126 text-transform: uppercase; letter-spacing: 0.06em; 2126 text-transform: uppercase; letter-spacing: 0.06em;
2127− padding: 2px 7px; border: 1px solid currentColor; border-radius: var(--r-pill, 999px); 2127+ padding: 2px 7px; border: 1.5px solid currentColor; border-radius: 3px;
2128− white-space: nowrap; 2128+ white-space: nowrap; vertical-align: 1px;
2129 } 2129 }
2130+.cr-poste .st-pill { margin-left: 8px; }
2130 .st-included { color: #2e7d4a; background: #eef7f0; } 2131 .st-included { color: #2e7d4a; background: #eef7f0; }
2131 .st-observed { color: var(--ink); background: var(--surface); } 2132 .st-observed { color: var(--ink); background: var(--surface); }
2132 .st-estimated { color: var(--accent-deep, #cc5500); background: var(--accent-soft, #fff1e6); } 2133 .st-estimated { color: var(--accent-deep, #cc5500); background: var(--accent-soft, #fff1e6); }
@@ -2136,6 +2137,8 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2136 .cr-table { width: 100%; border-collapse: collapse; font-size: 13.5px; } 2137 .cr-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
2137 .cr-table td { padding: 7px 8px 7px 0; border-bottom: 1px solid var(--line); vertical-align: top; } 2138 .cr-table td { padding: 7px 8px 7px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
2138 .cr-table tbody tr:nth-child(even) td { background: var(--surface-2, #faf9f5); } 2139 .cr-table tbody tr:nth-child(even) td { background: var(--surface-2, #faf9f5); }
2140+.cr-table tbody tr:hover td { background: var(--accent-soft, #fff1e6); }
2141+.cr-table tfoot tr:first-child td { background: var(--accent-soft, #fff1e6); }
2139 .cr-poste { color: var(--ink-2); } 2142 .cr-poste { color: var(--ink-2); }
2140 .cr-montant { 2143 .cr-montant {
2141 text-align: right; font-family: var(--font-mono); font-weight: 600; 2144 text-align: right; font-family: var(--font-mono); font-weight: 600;
@@ -2158,7 +2161,7 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2158 /* --- Historique Lou-Ka : timeline --- */ 2161 /* --- Historique Lou-Ka : timeline --- */
2159 .hl-timeline { 2162 .hl-timeline {
2160 list-style: none; margin: 12px 0 0; padding: 0 0 0 14px; 2163 list-style: none; margin: 12px 0 0; padding: 0 0 0 14px;
2161− border-left: 2px solid var(--line); 2164+ border-left: 2px solid var(--ink);
2162 display: flex; flex-direction: column; gap: 8px; 2165 display: flex; flex-direction: column; gap: 8px;
2163 } 2166 }
2164 .hl-item { position: relative; font-size: 13px; color: var(--ink-2); } 2167 .hl-item { position: relative; font-size: 13px; color: var(--ink-2); }
@@ -2208,10 +2211,14 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2208 .gest-head { margin-bottom: 10px; } 2211 .gest-head { margin-bottom: 10px; }
2209 .gest-nom { font-family: var(--font-display); font-weight: 700; font-size: 17px; } 2212 .gest-nom { font-family: var(--font-display); font-weight: 700; font-size: 17px; }
2210 .gest-meta { font-size: 12.5px; color: var(--ink-2); margin-top: 2px; } 2213 .gest-meta { font-size: 12.5px; color: var(--ink-2); margin-top: 2px; }
2211−.gest-google { display: flex; align-items: baseline; gap: 10px; margin: 8px 0; } 2214+.gest-google { display: flex; align-items: center; gap: 10px; margin: 10px 0; flex-wrap: wrap; }
2212 .gest-note { 2215 .gest-note {
2213− font-family: var(--font-display); font-weight: 700; font-size: 22px; 2216+ font-family: var(--font-display); font-weight: 700; font-size: 20px;
2214 color: var(--accent-deep, #cc5500); 2217 color: var(--accent-deep, #cc5500);
2218+ background: var(--surface); border: 1.5px solid var(--ink);
2219+ border-radius: var(--r-ctl); padding: 5px 10px;
2220+ box-shadow: 3px 3px 0 var(--accent-soft, #fff1e6);
2221+ white-space: nowrap;
2215 } 2222 }
2216 .gest-navis { font-size: 12.5px; color: var(--ink-2); } 2223 .gest-navis { font-size: 12.5px; color: var(--ink-2); }
2217 .gest-bars { margin: 10px 0; display: flex; flex-direction: column; gap: 4px; } 2224 .gest-bars { margin: 10px 0; display: flex; flex-direction: column; gap: 4px; }
@@ -2254,10 +2261,13 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2254 } 2261 }
2255 2262
2256 /* --- Vie quotidienne en hiver --- */ 2263 /* --- Vie quotidienne en hiver --- */
2257−.hiver-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } 2264+.hiver-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
2258 .hiver-score { 2265 .hiver-score {
2259− font-family: var(--font-display); font-weight: 700; font-size: 34px; 2266+ font-family: var(--font-display); font-weight: 700; font-size: 30px;
2260 line-height: 1; color: var(--ink); 2267 line-height: 1; color: var(--ink);
2268+ background: var(--surface); border: 1.5px solid var(--ink);
2269+ border-radius: var(--r-ctl); padding: 8px 12px;
2270+ box-shadow: 3px 3px 0 var(--accent-soft, #fff1e6);
2261 } 2271 }
2262 .hiver-sur { font-size: 12.5px; color: var(--ink-3); } 2272 .hiver-sur { font-size: 12.5px; color: var(--ink-3); }
2263 .hiver-detail { 2273 .hiver-detail {
@@ -2279,3 +2289,24 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2279 font-family: var(--font-mono); font-weight: 700; font-size: 12px; 2289 font-family: var(--font-mono); font-weight: 700; font-size: 12px;
2280 min-width: 28px; text-align: right; 2290 min-width: 28px; text-align: right;
2281 } 2291 }
2292+
2293+/* --- Adaptatif mobile : dossier d'intelligence locative --- */
2294+@media (max-width: 640px) {
2295+ .cr-table { font-size: 12.5px; }
2296+ .cr-table td { padding: 6px 6px 6px 0; }
2297+ .cr-poste .st-pill { margin-left: 6px; }
2298+ .cr-montant { font-size: 12.5px; }
2299+ .hl-date { display: block; min-width: 0; margin-bottom: 2px; }
2300+ .hl-item { font-size: 12.5px; }
2301+ .hl-match { font-size: 12.5px; }
2302+ .im-indic .im-detail { display: block; margin-top: 3px; }
2303+ .gest-nom { font-size: 16px; }
2304+ .gest-stats { font-size: 12.5px; gap: 8px; }
2305+ .gest-citation { font-size: 12.5px; }
2306+ .gb-n { min-width: 24px; font-size: 10.5px; }
2307+ .gb-c { min-width: 20px; font-size: 10.5px; }
2308+ .hiver-score { font-size: 26px; padding: 6px 10px; }
2309+ .hiver-sur { flex-basis: 100%; }
2310+ .hd-crit { min-width: 86px; font-size: 10px; }
2311+ .hiver-detail li { font-size: 12.5px; }
2312+}
modified louka/pdfgen.py +337 −2
@@ -4,9 +4,12 @@
4 # pdfgen.py : génération PDF à l'identité visuelle officielle Lou-Ka 4 # pdfgen.py : génération PDF à l'identité visuelle officielle Lou-Ka
5 # - fiche_pdf(uid) : fiche de propriété complète — tout ce que montre la 5 # - fiche_pdf(uid) : fiche de propriété complète — tout ce que montre la
6 # fiche web : photos, prix + analyse Lou-Ka (juste valeur, histogramme), 6 # fiche web : photos, prix + analyse Lou-Ka (juste valeur, histogramme),
7−# chips, description structurée, inclusions, registre des loyers, 7+# chips, description structurée, inclusions, coût réel mensuel,
8+# historique Lou-Ka (+ vies antérieures), passeport de l'immeuble,
9+# gestionnaire (fiche Google + avis synchronisés), registre des loyers,
8 # historique TAL, inondation, qualité de l'air, coût d'électricité, 10 # historique TAL, inondation, qualité de l'air, coût d'électricité,
9−# commerces/transport, essence, KA Scores, quartier, à proximité, QR 11+# commerces/transport, essence, KA Scores, score hiver, quartier,
12+# à proximité, QR
10 # - rapport_pdf() : rapport global du marché (totaux, régions, histogramme 13 # - rapport_pdf() : rapport global du marché (totaux, régions, histogramme
11 # des loyers, types, villes, gestionnaires) 14 # des loyers, types, villes, gestionnaires)
12 # Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00, 15 # Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00,
@@ -241,6 +244,15 @@ def _fmt_mois(d: str | None) -> str:
241 return d 244 return d
242 245
243 246
247+def _fmt_ts(ts, jour: bool = False) -> str:
248+ """Timestamp unix → « 6 août 2026 » (jour=True) ou « août 2026 »."""
249+ if not ts:
250+ return "—"
251+ t = time.localtime(ts)
252+ mois = f"{_MOIS[t.tm_mon - 1]} {t.tm_year}"
253+ return f"{t.tm_mday} {mois}" if jour else mois
254+
255+
244 _VERDICTS = { 256 _VERDICTS = {
245 "sous": ("Sous le marché", GREEN_DEEP, LIME_SOFT, GREEN), 257 "sous": ("Sous le marché", GREEN_DEEP, LIME_SOFT, GREEN),
246 "marche": ("Dans le marché", INK2, SURFACE, INK), 258 "marche": ("Dans le marché", INK2, SURFACE, INK),
@@ -320,6 +332,20 @@ def fiche_pdf(uid: str) -> bytes | None:
320 com = _safe(com_mod.nearby, lat, lng) if geo else None 332 com = _safe(com_mod.nearby, lat, lng) if geo else None
321 hyd = _safe(hydro_mod.estimate, adresse=l.get("address") or "", 333 hyd = _safe(hydro_mod.estimate, adresse=l.get("address") or "",
322 uid=uid, lat=lat, lng=lng, solve=False) 334 uid=uid, lat=lat, lng=lng, solve=False)
335+ # dossier d'intelligence locative (mêmes modules que les blocs du site)
336+ from . import building as bld_mod
337+ from . import coutreel as cr_mod
338+ from . import history as hist_mod
339+ from . import hiver as hiv_mod
340+ from . import managers as mgr_mod
341+ from . import recycled as rec_mod
342+ cout = _safe(cr_mod.compute, uid)
343+ histo = _safe(hist_mod.timeline, uid)
344+ recyc = _safe(rec_mod.lookup, uid)
345+ imm = (_safe(bld_mod.fiche, l["building_key"])
346+ if l.get("building_key") else None)
347+ hiv = _safe(hiv_mod.score, lat, lng) if geo else None
348+ gest = _safe(mgr_mod.fiche, l["source"])
323 349
324 buf = io.BytesIO() 350 buf = io.BytesIO()
325 c = rl_canvas.Canvas(buf, pagesize=letter) 351 c = rl_canvas.Canvas(buf, pagesize=letter)
@@ -568,6 +594,277 @@ def fiche_pdf(uid: str) -> bytes | None:
568 c.drawString(M, y - 2, f"Historique de prix de l'annonce : {chn}") 594 c.drawString(M, y - 2, f"Historique de prix de l'annonce : {chn}")
569 y -= 16 595 y -= 16
570 596
597+ # --- Coût réel mensuel (loyer + frais, chaque poste étiqueté honnêtement)
598+ if cout and cout.get("loyer") is not None:
599+ lignes_cr = cout.get("lignes") or []
600+ y = place(y, 58 + len(lignes_cr) * 12 + 46)
601+ y = s.titre_section(y, "Coût réel mensuel")
602+ stat_cr = {"included": ("INCLUS", GREEN), "observed": ("OBSERVÉ", INK),
603+ "estimated": ("ESTIMÉ", LIME), "unknown": ("INCONNU", INK3)}
604+ for li in lignes_cr:
605+ lab, col = stat_cr.get(li.get("statut"), stat_cr["unknown"])
606+ c.setFont("Helvetica", 8.5)
607+ c.setFillColor(INK2)
608+ c.drawString(M, y, (li.get("poste") or "")[:46])
609+ c.setFont("Courier-Bold", 6.5)
610+ c.setFillColor(col)
611+ c.drawString(M + 230, y, lab)
612+ m_ = li.get("montant")
613+ if m_ is not None and m_ > 0:
614+ mtx = _fmt_money(m_)
615+ elif m_ == 0 and li.get("statut") == "included":
616+ mtx = f"0{NBSP}$"
617+ else:
618+ mtx = "—"
619+ c.setFont("Helvetica-Bold", 8.5)
620+ c.setFillColor(INK)
621+ c.drawRightString(PAGE_W - M, y, mtx)
622+ y -= 12
623+ if cout.get("total_estime") is not None:
624+ c.setStrokeColor(INK)
625+ c.setLineWidth(1.2)
626+ c.line(M, y + 8, PAGE_W - M, y + 8)
627+ c.setFont("Helvetica-Bold", 10)
628+ c.setFillColor(INK)
629+ c.drawString(M, y - 4, "Total estimé")
630+ tot = f"≈{NBSP}{_fmt_money(cout['total_estime'])} / mois"
631+ if cout.get("annuel_estime") is not None:
632+ tot += f" · ≈{NBSP}{_fmt_money(cout['annuel_estime'])} / an"
633+ c.drawRightString(PAGE_W - M, y - 4, tot)
634+ y -= 18
635+ if cout.get("postes_inconnus"):
636+ y = s.paragraphe(M, y - 2, "Postes non chiffrables avec les données "
637+ "publiées : "
638+ + ", ".join(cout["postes_inconnus"]).lower()
639+ + " — le total réel peut être plus élevé.",
640+ PAGE_W - 2 * M, taille=7.5,
641+ couleur=HexColor("#8a5a12"), interligne=9.5,
642+ max_lignes=3)
643+ pi2_d = cout.get("pi2")
644+ if pi2_d and pi2_d.get("valeur") is not None:
645+ v = f"{pi2_d['valeur']:.2f}".replace(".", ",")
646+ suite = f"{v}{NBSP}$/pi²"
647+ if pi2_d.get("percentile_secteur") is not None:
648+ suite += (f" — moins cher que {100 - pi2_d['percentile_secteur']}"
649+ f"{NBSP}% des {pi2_d.get('n_secteur')} comparables du "
650+ "secteur (~2 km)")
651+ elif pi2_d.get("percentile_ville") is not None:
652+ suite += (f" — moins cher que {100 - pi2_d['percentile_ville']}"
653+ f"{NBSP}% des {pi2_d.get('n_ville')} comparables "
654+ f"({pi2_d.get('portee_ville', '')})")
655+ c.setFont("Helvetica", 8)
656+ c.setFillColor(INK2)
657+ c.drawString(M, y - 2, suite[:120])
658+ y -= 12
659+ y -= 8
660+
661+ # --- Historique Lou-Ka (observations réelles des synchronisations)
662+ if histo:
663+ tl = (histo.get("timeline") or [])[:8]
664+ matches = ((recyc or {}).get("matches") or [])[:2]
665+ y = place(y, 46 + len(tl) * 11 + len(matches) * 22 + 18)
666+ y = s.titre_section(y, "Historique Lou-Ka")
667+ entete_h = (f"Suivie depuis {_fmt_ts(histo.get('premiere_observation'), jour=True)}"
668+ f" · {histo.get('jours_en_ligne', 0)} jour"
669+ f"{'s' if (histo.get('jours_en_ligne') or 0) > 1 else ''} en ligne"
670+ f" · {histo.get('modifications', 0)} modification"
671+ f"{'s' if (histo.get('modifications') or 0) > 1 else ''} observée"
672+ f"{'s' if (histo.get('modifications') or 0) > 1 else ''}")
673+ pi_, pa_ = histo.get("prix_initial"), histo.get("prix_actuel")
674+ if pi_ is not None and pa_ is not None and pi_ != pa_:
675+ var_ = histo.get("variation")
676+ entete_h += f" · {_fmt_money(pi_)} → {_fmt_money(pa_)}"
677+ if var_ is not None:
678+ entete_h += (f" ({'+' if var_ > 0 else '−'}"
679+ f"{abs(round(var_ * 100))}{NBSP}%)")
680+ c.setFont("Helvetica-Bold", 8.5)
681+ c.setFillColor(INK)
682+ c.drawString(M, y - 2, entete_h[:120])
683+ y -= 16
684+ ev_labels = {"description": "Description modifiée",
685+ "superficie": "Superficie modifiée",
686+ "dispo": "Disponibilité modifiée",
687+ "inclusions": "Inclusions modifiées",
688+ "photos": "Photos modifiées",
689+ "disparition": "Annonce retirée",
690+ "reapparition": "Annonce republiée"}
691+ if tl:
692+ for it in tl:
693+ c.setFont("Courier-Bold", 7)
694+ c.setFillColor(INK3)
695+ c.drawString(M, y, _fmt_ts(it.get("ts"), jour=True).upper())
696+ c.setFont("Helvetica", 8)
697+ c.setFillColor(INK2)
698+ if it.get("type") == "prix":
699+ if it.get("prix_avant") is not None:
700+ sens = ("baissé" if it["prix_avant"] > (it.get("prix") or 0)
701+ else "monté")
702+ txt_ev = (f"Prix {sens} de {_fmt_money(it['prix_avant'])} "
703+ f"à {_fmt_money(it.get('prix'))}")
704+ else:
705+ txt_ev = f"Premier prix observé : {_fmt_money(it.get('prix'))}"
706+ else:
707+ txt_ev = ev_labels.get(it.get("type"), it.get("type") or "")
708+ c.drawString(M + 78, y, txt_ev[:95])
709+ y -= 11
710+ else:
711+ c.setFont("Helvetica", 8)
712+ c.setFillColor(INK2)
713+ c.drawString(M, y, "Aucune modification observée depuis la première "
714+ "synchronisation de cette annonce.")
715+ y -= 11
716+ for m_ in matches:
717+ suite = f"Vie antérieure probable ({m_.get('confiance')}{NBSP}% de confiance)"
718+ if m_.get("prix") is not None:
719+ suite += f" — affiché {_fmt_money(m_['prix'])}"
720+ if m_.get("derniere_observation"):
721+ suite += f" jusqu'en {_fmt_ts(m_['derniere_observation'])}"
722+ c.setFont("Helvetica-Bold", 8)
723+ c.setFillColor(HexColor("#8a5a12"))
724+ c.drawString(M, y - 2, suite[:115])
725+ y -= 10
726+ c.setFont("Helvetica", 7)
727+ c.setFillColor(INK3)
728+ c.drawString(M, y - 2, ("signaux : " + " · ".join(m_.get("signaux") or []))[:125])
729+ y -= 12
730+ y -= 8
731+
732+ # --- Passeport de l'immeuble (ce que Lou-Ka observe du bâtiment)
733+ if imm and (imm.get("annonces_total") or 0) >= 2:
734+ y = place(y, 128)
735+ y = s.titre_section(y, "Passeport de l'immeuble")
736+ tw = (PAGE_W - 2 * M - 30) / 4
737+ rot = imm.get("rotation") or {}
738+ pres = imm.get("pression_loyers")
739+ tuiles_im = [
740+ (str(imm["annonces_total"]),
741+ f"annonces obs. ({imm.get('annonces_actives', 0)} actives)"),
742+ (f"≥{NBSP}{imm.get('unites_estimees', 0)}", "unités estimées"),
743+ (_fmt_money(imm.get("loyer_median")), "loyer médian (actives)"),
744+ (f"{imm['pi2_median']:.2f}".replace(".", ",") + f"{NBSP}$"
745+ if imm.get("pi2_median") is not None else "—", "médiane $/pi²"),
746+ ]
747+ for i, (val, lab) in enumerate(tuiles_im):
748+ s.tuile(M + i * (tw + 10), y - 44, tw, 38, val, lab)
749+ y -= 58
750+ par_cc = imm.get("loyer_median_par_cc") or {}
751+ if par_cc:
752+ parts = [f"{cc}{NBSP}ch. {_fmt_money(v)}"
753+ for cc, v in sorted(par_cc.items())]
754+ c.setFont("Helvetica", 8)
755+ c.setFillColor(INK2)
756+ c.drawString(M, y, ("Loyer médian par nombre de chambres : "
757+ + " · ".join(parts))[:118])
758+ y -= 13
759+ c.setFont("Helvetica-Bold", 8)
760+ c.setFillColor(INK)
761+ c.drawString(M, y, "Rotation des logements :")
762+ c.setFont("Helvetica", 8)
763+ c.setFillColor(INK2)
764+ if rot.get("statut") == "calculated":
765+ c.drawString(M + 118, y,
766+ (f"{rot.get('classe', '')} — {rot.get('annonces_12m')} "
767+ f"annonces sur 12 mois pour ≥{NBSP}"
768+ f"{rot.get('unites_estimees')} unités")[:100])
769+ else:
770+ c.drawString(M + 118, y, "données insuffisantes")
771+ y -= 12
772+ c.setFont("Helvetica-Bold", 8)
773+ c.setFillColor(INK)
774+ c.drawString(M, y, "Pression sur les loyers :")
775+ c.setFont("Helvetica", 8)
776+ c.setFillColor(INK2)
777+ if pres:
778+ v12 = pres.get("variation_12m") or 0
779+ c.drawString(M + 118, y,
780+ (f"{'+' if v12 > 0 else '−'}{abs(round(v12 * 100))}"
781+ f"{NBSP}% sur 12 mois — médiane des prix d'entrée "
782+ f"{_fmt_money(pres.get('mediane_12_24m'))} → "
783+ f"{_fmt_money(pres.get('mediane_12m'))} "
784+ f"({pres.get('n_12_24m')} vs {pres.get('n_12m')} annonces)")[:110])
785+ else:
786+ c.drawString(M + 118, y, "échantillon insuffisant")
787+ y -= 12
788+ c.setFont("Helvetica", 6.5)
789+ c.setFillColor(INK3)
790+ c.drawString(M, y - 2, (f"Immeuble suivi par Lou-Ka depuis "
791+ f"{_fmt_ts(imm.get('premiere_observation'))} — "
792+ "indicateurs calculés uniquement sur les annonces "
793+ "observées (pas un recensement du bâtiment).")[:135])
794+ y -= 16
795+
796+ # --- Qui gère ce logement? (fiche Google associée + avis synchronisés)
797+ g_ = (gest or {}).get("google_maps")
798+ if gest and g_ and g_.get("statut") != "non_associe":
799+ avis_g = gest.get("avis") or {}
800+ dist_g = avis_g.get("distribution") or {}
801+ tot_g = sum(dist_g.values()) if dist_g else 0
802+ y = place(y, 108 + (5 * 11 if tot_g else 0))
803+ y = s.titre_section(y, "Qui gère ce logement?")
804+ c.setFont("Helvetica-Bold", 10.5)
805+ c.setFillColor(INK)
806+ c.drawString(M, y - 2, (gest.get("nom") or "")[:60])
807+ n_act = gest.get("annonces_actives") or 0
808+ c.setFont("Helvetica", 8)
809+ c.setFillColor(INK2)
810+ c.drawString(M + c.stringWidth((gest.get("nom") or "")[:60],
811+ "Helvetica-Bold", 10.5) + 10, y - 2,
812+ f"{n_act} annonce{'s' if n_act > 1 else ''} active"
813+ f"{'s' if n_act > 1 else ''} sur Lou-Ka")
814+ y -= 16
815+ if g_.get("note") is not None:
816+ note_t = f"{g_['note']:.1f}".replace(".", ",")
817+ s.pilule(M, y - 10, f"Note Google {note_t} / 5", LIME, INK)
818+ c.setFont("Helvetica", 8)
819+ c.setFillColor(INK2)
820+ c.drawString(M + 110, y - 5,
821+ (f"{g_.get('nombre_avis', 0)}{NBSP}avis Google — fiche "
822+ f"« {g_.get('nom', '')} »")[:90])
823+ y -= 22
824+ if tot_g:
825+ bw_g = 150.0
826+ for n_ in (5, 4, 3, 2, 1):
827+ cnt = dist_g.get(str(n_), 0)
828+ c.setFont("Courier-Bold", 7)
829+ c.setFillColor(INK2)
830+ c.drawString(M, y, f"{n_}*")
831+ c.setFillColor(SURFACE)
832+ c.setStrokeColor(INK)
833+ c.setLineWidth(0.8)
834+ c.rect(M + 22, y - 1, bw_g, 7, stroke=1, fill=1)
835+ if cnt:
836+ c.setFillColor(GREEN if n_ >= 4 else (LIME if n_ <= 2 else INK3))
837+ c.rect(M + 22, y - 1, bw_g * cnt / tot_g, 7, stroke=0, fill=1)
838+ c.setFont("Courier-Bold", 7)
839+ c.setFillColor(INK)
840+ c.drawString(M + 22 + bw_g + 6, y, str(cnt))
841+ y -= 11
842+ y -= 2
843+ stats_g = []
844+ if avis_g.get("moyenne_12m") is not None:
845+ stats_g.append(f"moyenne des 12 derniers mois : "
846+ f"{avis_g['moyenne_12m']:.1f}".replace(".", ",")
847+ + f" ({avis_g.get('n_12m')} avis)")
848+ if avis_g.get("tendance"):
849+ stats_g.append(avis_g["tendance"])
850+ if avis_g.get("plaintes_frequentes"):
851+ stats_g.append("plaintes récurrentes : "
852+ + ", ".join(avis_g["plaintes_frequentes"][:4]))
853+ if stats_g:
854+ y = s.paragraphe(M, y, " · ".join(stats_g), PAGE_W - 2 * M,
855+ taille=8, couleur=INK2, interligne=10,
856+ max_lignes=2)
857+ y -= 2
858+ conf_g = round((g_.get("confiance_association") or 0) * 100)
859+ y = s.paragraphe(M, y - 2, f"Fiche Google Maps associée automatiquement "
860+ f"(confiance {conf_g}{NBSP}%). Statistiques sur "
861+ f"les {avis_g.get('n', 0)} avis synchronisés dans "
862+ "la base Lou-Ka — échantillon des plus récents, "
863+ "pas une lecture humaine.",
864+ PAGE_W - 2 * M, taille=6.5, couleur=INK3,
865+ interligne=8.5, max_lignes=3)
866+ y -= 6
867+
571 # --- Registre des loyers (déclarations citoyennes à proximité) 868 # --- Registre des loyers (déclarations citoyennes à proximité)
572 if rdl_d and rdl_d.get("n"): 869 if rdl_d and rdl_d.get("n"):
573 comp = rdl_d.get("items") or [] 870 comp = rdl_d.get("items") or []
@@ -815,6 +1112,44 @@ def fiche_pdf(uid: str) -> bytes | None:
815 f"{lab} · {sub}" if sub else lab) 1112 f"{lab} · {sub}" if sub else lab)
816 y -= 62 1113 y -= 62
817 1114
1115+ # --- Vie quotidienne en hiver (quotidien à pied, même à −20 °C)
1116+ if hiv:
1117+ det_h = hiv.get("detail") or []
1118+ y = place(y, 52 + len(det_h) * 11 + 14)
1119+ y = s.titre_section(y, "Vie quotidienne en hiver")
1120+ c.setFont("Helvetica-Bold", 17)
1121+ c.setFillColor(INK)
1122+ sc_t = str(hiv.get("score", "—"))
1123+ c.drawString(M, y - 8, sc_t)
1124+ s.pilule(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 10, y - 12,
1125+ (hiv.get("classe") or "").upper())
1126+ c.setFont("Helvetica", 8)
1127+ c.setFillColor(INK3)
1128+ c.drawString(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 105, y - 7,
1129+ "quotidien à pied, même à −20 °C")
1130+ y -= 26
1131+ for cr_ in det_h:
1132+ c.setFont("Helvetica-Bold", 8)
1133+ c.setFillColor(INK)
1134+ c.drawString(M, y, (cr_.get("critere") or "")[:24])
1135+ c.setFont("Helvetica", 8)
1136+ c.setFillColor(INK2)
1137+ if cr_.get("distance_m") is not None:
1138+ val_h = f"≈{NBSP}{cr_.get('minutes')}{NBSP}min à pied"
1139+ if cr_.get("nom"):
1140+ val_h += f" ({cr_['nom']})"
1141+ else:
1142+ val_h = cr_.get("note") or "—"
1143+ c.drawString(M + 90, y, val_h[:80])
1144+ c.setFont("Courier-Bold", 7.5)
1145+ c.setFillColor(INK)
1146+ c.drawRightString(PAGE_W - M, y, str(cr_.get("score", "—")))
1147+ y -= 11
1148+ c.setFont("Helvetica", 6.5)
1149+ c.setFillColor(INK3)
1150+ c.drawString(M, y - 2, (hiv.get("methode") or "")[:135])
1151+ y -= 16
1152+
818 # --- Le quartier 1153 # --- Le quartier
819 if quartier: 1154 if quartier:
820 y = place(y, 270) 1155 y = place(y, 270)
821 1156
Menu

Theme