// ----------------------------------------------------------------------------- // Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // fiche/synthese.ts : synthèse DÉTERMINISTE de la fiche (aucun LLM, aucune // donnée inventée) — briques réutilisées par le héro, le score, « En bref », // l'aside desktop et le CTA sticky : // · comparaisonPrix : position du prix demandé vs estimation Vrai-Prix ; // · immoKaScore : 0-100 = 60 % emplacement (indice de proximité StatCan du // secteur) + 40 % prix (écart à l'estimation Vrai-Prix : 0 % → 65, // −30 % → 100, +30 % → 20). Score PARTIEL et dit tel quel quand une // composante manque ; // · enBref : 4 à 6 constats priorisés (prix, rôle d'évaluation, accessibilité, // air, inondation, taxes/copropriété, chaleur, baisse de prix, temps sur le // marché, publications multiples), chacun avec son ton et sa preuve ; // · lecture des caractéristiques Centris (taxes, copropriété, évaluation). // ----------------------------------------------------------------------------- import { AirNearby, Inondation, Listing, fmtPrice } from "../api"; import { Tone, NBSP, fmtPct, parseMontant } from "./ui"; /* --- caractéristiques chiffrées lues dans `details` --------------------------- */ const pick = (l: Listing, keys: string[]): unknown => { const d = l.details ?? {}; for (const k of keys) if (d[k] != null && String(d[k]).trim()) return d[k]; return null; }; export const estLocation = (l: Listing) => l.details?.transaction === "location"; /** Taxes annuelles publiées (municipales + scolaires), en $ ; null si absentes. */ export function taxesAnnuelles(l: Listing): { total: number; municipales: number | null; scolaires: number | null } | null { const m = parseMontant(pick(l, ["Taxes municipales", "Taxe municipale"])); const s = parseMontant(pick(l, ["Taxes scolaires", "Taxe scolaire"])); const tot = (m ?? 0) + (s ?? 0); if (tot <= 0 || tot > 500_000) return null; return { total: tot, municipales: m, scolaires: s }; } /** Frais de copropriété mensuels publiés, en $ ; null si absents. */ export function fraisCoproMensuels(l: Listing): number | null { const v = pick(l, ["Frais de copropriété", "Frais de condo", "Frais de copropriété (mensuels)", "Frais communs"]); const n = parseMontant(v); if (n == null) return null; // certaines sources publient un montant annuel « /an » return /an\b|année|annuel/i.test(String(v)) ? Math.round(n / 12) : n; } /** Évaluation municipale (rôle) : totale, terrain, bâtiment, année. */ export function evaluationMunicipale(l: Listing): { total: number; terrain: number | null; batiment: number | null; annee: string | null } | null { const vp = l.vraiprix; let total = parseMontant(pick(l, ["Évaluation municipale", "Évaluation municipale (totale)", "Évaluation municipale totale"])); const terrain = parseMontant(pick(l, ["Évaluation municipale (terrain)"])) ?? vp?.valeur_terrain ?? null; const batiment = parseMontant(pick(l, ["Évaluation municipale (bâtiment)"])) ?? vp?.valeur_batiment ?? null; if (total == null && terrain != null && batiment != null) total = terrain + batiment; if (total == null && vp?.valeur_role) total = vp.valeur_role; if (total == null) return null; const annee = pick(l, ["Évaluation municipale (année)"]); return { total, terrain, batiment, annee: annee != null ? String(annee) : null }; } /* --- comparaison de prix (Vrai-Prix) ------------------------------------------ */ export interface ComparaisonPrix { tone: "good" | "ok" | "high"; pct: number; // écart signé en % (négatif = sous l'estimation) label: string; // « Sous l'estimation » / « Prix aligné » / « Au-dessus de l'estimation » court: string; // « ↓ 8 % vs estimation » ref: number; // valeur de référence ($) refLabel: string; confidence: string | null; // A | B | C | D } export function comparaisonPrix(l: Listing): ComparaisonPrix | null { const vp = l.vraiprix; if (l.price == null || estLocation(l) || !vp || vp.value == null || vp.value <= 0) return null; const pct = Math.round(((l.price - vp.value) / vp.value) * 100); if (Math.abs(pct) > 60) return null; // prix non comparable (terrain, commerce, lot…) const tone = pct <= -5 ? "good" : pct <= 5 ? "ok" : "high"; return { tone, pct, ref: vp.value, refLabel: "estimation Vrai-Prix", confidence: vp.confidence, label: tone === "good" ? "Sous l'estimation" : tone === "ok" ? "Prix aligné" : "Au-dessus de l'estimation", court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs estimation`, }; } /* --- Immo-Ka Score -------------------------------------------------------------- */ export interface ImmoKaScore { value: number | null; // score affiché (0-100) ou null si rien de fiable partial: boolean; // une composante manque emplacement: number | null; // indice de proximité StatCan (0-100) prix: number | null; // composante prix (0-100) label: string | null; explication: string; } export function kaLabel(score: number | null | undefined): string | null { if (score == null) return null; if (score >= 85) return "Exceptionnel"; if (score >= 70) return "Excellent"; if (score >= 55) return "Très bon"; if (score >= 40) return "Moyen"; return "Faible"; } export function scorePrix(deviation: number | null | undefined): number | null { if (deviation == null) return null; // 0 % d'écart → 65 ; −30 % → 100 ; +30 % → 20 (borné 5-100) return Math.round(Math.max(5, Math.min(100, 65 - deviation * 150))); } /** Indice d'emplacement 0-100 = moyenne des mesures de proximité StatCan * (épiceries, transport, pharmacies, parcs, écoles, santé…) du secteur. */ export function indiceEmplacement(l: Listing): number | null { const p = l.quartier?.proximite; if (!p) return null; const vals = Object.entries(p) .filter(([k, v]) => k.startsWith("prox_") && typeof v === "number" && Number.isFinite(v)) .map(([, v]) => Math.max(0, Math.min(1, v as number))); if (vals.length < 3) return null; return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100); } export function immoKaScore(l: Listing): ImmoKaScore { const emplacement = indiceEmplacement(l); const cmp = comparaisonPrix(l); const prix = cmp ? scorePrix(cmp.pct / 100) : null; let value: number | null = null; let partial = false; if (emplacement != null && prix != null) value = Math.round(emplacement * 0.6 + prix * 0.4); else if (emplacement != null) { value = emplacement; partial = true; } else if (prix != null) { value = prix; partial = true; } const explication = "L'Immo-Ka Score combine l'emplacement (indice de proximité aux services de Statistique Canada " + "pour le secteur : épiceries, transport en commun, pharmacies, parcs, écoles, soins de santé — 60 %) " + "et le prix (écart du prix demandé à l'estimation Vrai-Prix, fondée sur les ventes comparables et le " + "rôle d'évaluation — 40 %). Quand une composante manque, le score est dit partiel et repose sur la seule " + "composante disponible. Il n'intègre ni l'état du bâtiment ni l'inspection."; return { value, partial, emplacement, prix, label: value != null ? kaLabel(value) : null, explication }; } /* --- En bref -------------------------------------------------------------------- */ export interface Constat { tone: Tone; titre: string; detail: string; cle: string; } export function enBref(l: Listing, x: { air: AirNearby | null; inondation: Inondation | null; loadingRisques: boolean; }): Constat[] { const out: Constat[] = []; const cmp = comparaisonPrix(l); const location = estLocation(l); // 1. prix vs estimation Vrai-Prix if (cmp) { out.push({ cle: "prix", tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral", titre: cmp.tone === "good" ? "Prix demandé sous l'estimation" : cmp.tone === "high" ? "Prix demandé au-dessus de l'estimation" : "Prix aligné sur l'estimation", detail: `${Math.abs(cmp.pct)}${NBSP}% ${cmp.pct < 0 ? "sous" : cmp.pct > 0 ? "au-dessus de" : "≈"} ${fmtPrice(cmp.ref)} (Vrai-Prix${cmp.confidence ? `, confiance ${cmp.confidence}` : ""})`, }); } else if (l.price != null && !location) { out.push({ cle: "prix", tone: "neutral", titre: "Prix non comparé", detail: "Pas d'estimation Vrai-Prix exploitable pour ce type de bien." }); } // 2. rôle d'évaluation const ev = evaluationMunicipale(l); if (ev && l.price != null && !location && ev.total > 0) { const pct = Math.round(((l.price - ev.total) / ev.total) * 100); if (Math.abs(pct) <= 150) out.push({ cle: "role", tone: "neutral", titre: `${Math.abs(pct)}${NBSP}% ${pct >= 0 ? "au-dessus" : "sous"} l'évaluation municipale`, detail: `Rôle ${ev.annee ? `${ev.annee} ` : ""}: ${fmtPrice(ev.total)}${ev.terrain != null && ev.batiment != null ? ` (terrain ${fmtPrice(ev.terrain)}, bâtiment ${fmtPrice(ev.batiment)})` : ""}` }); } // 3. accessibilité (proximité StatCan) const m = indiceEmplacement(l); if (m != null) out.push({ cle: "acces", tone: m >= 70 ? "good" : m >= 45 ? "neutral" : "warn", titre: m >= 85 ? "Secteur exceptionnellement bien desservi" : m >= 70 ? "Services et transport à proximité" : m >= 45 ? "Accessibilité moyenne" : "Secteur peu desservi sans voiture", detail: `Indice de proximité StatCan ${m}/100 (épiceries, transport, pharmacies, parcs, écoles, santé)` }); // 4. qualité de l'air if (x.air?.station) { const pm = x.air.mesures?.["PM2.5"]; if (pm?.ref) { const r = pm.moyenne / pm.ref; out.push({ cle: "air", tone: r <= 2 ? "good" : r <= 3 ? "warn" : "bad", titre: r <= 1 ? "Air de très bonne qualité" : r <= 2 ? "Bonne qualité de l'air" : r <= 3 ? "Qualité de l'air passable" : "Particules fines élevées", detail: `PM2,5 ${pm.moyenne.toLocaleString("fr-CA")}${NBSP}µg/m³ · station ${x.air.station}${x.air.distance_km != null ? ` (${x.air.distance_km.toLocaleString("fr-CA")} km)` : ""} · ${pm.annee}`, }); } } // 5. inondation if (x.inondation) { const d = x.inondation; if (d.statut === "en_zone") out.push({ cle: "inond", tone: d.severite === "eleve" ? "bad" : "warn", titre: "Adresse en zone inondable", detail: d.zones[0] ? `${d.zones[0].type}${d.zones[0].recurrence ? ` (${d.zones[0].recurrence})` : ""} — carte officielle BDZI` : "Cartographie officielle BDZI" }); else if (d.statut === "a_proximite") out.push({ cle: "inond", tone: "warn", titre: "Zone inondable à proximité", detail: d.zones[0] ? `${d.zones[0].type} à ~${d.zones[0].distance_m} m` : "Selon la cartographie BDZI" }); else if (d.statut === "hors_zone") out.push({ cle: "inond", tone: "good", titre: "Hors zone inondable", detail: "Secteur cartographié (BDZI, gouvernement du Québec)" }); else out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation indéterminé", detail: "Secteur non couvert par la cartographie officielle" }); } else if (x.loadingRisques && l.lat != null) { out.push({ cle: "inond", tone: "neutral", titre: "Risque d'inondation", detail: "Vérification en cours…" }); } // 6. charges : taxes et copropriété const tx = taxesAnnuelles(l); const copro = fraisCoproMensuels(l); if (tx || copro != null) { const parts: string[] = []; if (tx) parts.push(`taxes ${fmtPrice(Math.round(tx.total))}${NBSP}/an (≈${NBSP}${fmtPrice(Math.round(tx.total / 12))}${NBSP}/mois)`); if (copro != null) parts.push(`copropriété ${fmtPrice(copro)}${NBSP}/mois`); const mensuel = Math.round((tx ? tx.total / 12 : 0) + (copro ?? 0)); out.push({ cle: "charges", tone: "neutral", titre: `Charges fixes ≈ ${fmtPrice(mensuel)}${NBSP}/mois hors hypothèque`, detail: parts.join(" · ") }); } // 7. îlot de chaleur marqué if (l.quartier?.chaleur && l.quartier.chaleur.classe >= 8) out.push({ cle: "chaleur", tone: "warn", titre: "Îlot de chaleur urbain", detail: `Secteur parmi les plus chauds (classe ${l.quartier.chaleur.classe}/9${l.quartier.chaleur.ecart != null ? `, +${l.quartier.chaleur.ecart.toFixed(1)}${NBSP}°C` : ""}) — INSPQ` }); // 8. baisse de prix observée const hist = (l.price_history ?? []).filter((h) => h.price != null); if (hist.length >= 2 && hist[0].price! < hist[1].price!) out.push({ cle: "baisse", tone: "good", titre: "Prix en baisse", detail: `${fmtPrice(hist[1].price!)} → ${fmtPrice(hist[0].price!)} (${fmtPct(((hist[0].price! - hist[1].price!) / hist[1].price!) * 100)})` }); // 9. temps sur le marché (observé par Immo-Ka) if ((l.days_on_market ?? 0) >= 60) out.push({ cle: "marche", tone: "neutral", titre: `Sur le marché depuis ${l.days_on_market}${NBSP}jours`, detail: "Observé par Immo-Ka depuis la première synchronisation — marge de négociation possible" }); // 10. publications multiples if ((l.duplicates?.length ?? 0) > 0) out.push({ cle: "dups", tone: "neutral", titre: `Aussi publiée sur ${l.duplicates!.length} autre${l.duplicates!.length > 1 ? "s" : ""} plateforme${l.duplicates!.length > 1 ? "s" : ""}`, detail: "Immo-Ka affiche la version la plus complète — voir le dossier" }); return out.slice(0, 6); } /** Ligne résumé : « Maison · 3 chambres · 2 sdb · 1 500 pi² · construit en 1998 » */ export function ligneResume(l: Listing): string[] { const p: string[] = []; if (l.property_type) p.push(l.property_type); if (l.bedrooms != null) p.push(`${Math.round(l.bedrooms)} chambre${l.bedrooms > 1 ? "s" : ""}`); if (l.bathrooms != null) p.push(`${l.bathrooms} sdb${l.powder_rooms ? ` + ${l.powder_rooms} s.e.` : ""}`); if (l.area_sqft) p.push(`${Math.round(l.area_sqft).toLocaleString("fr-CA")}${NBSP}pi²`); else if (l.lot_sqft) p.push(`terrain ${Math.round(l.lot_sqft).toLocaleString("fr-CA")}${NBSP}pi²`); if (l.year_built) p.push(`construit en ${l.year_built}`); return p; }