spb/valoplex Public
ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.
TypeScript 90.3%
Python 7.1%
CSS 2.5%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Ka — la panoplie d'outils de l'agent IA de ValoPlex.4 * Spécialisée plex : évaluation en ratio, économie par porte, pro forma5 * investisseur « tout le kit ». Exécutée côté serveur sur la base SQLite6 * (393 867 plex) et le moteur d'estimation.7 */8import Anthropic from "@anthropic-ai/sdk";9import { getDb, getMarketIndex, getUnit, searchUnits } from "../db";10import {11 estimateByUnitId,12 estimateManual,13 estimatePortfolio,14 type UnitEstimate,15} from "../estimator";16import { buildProforma, DEFAULT_PARAMS, tgaReference, type ProformaParams } from "../proforma";17import stats from "../../data/stats.json";1819/* ---------------------------------------------------------------- helpers */2021const r = (n: number | null | undefined, d = 0): number | null =>22 n == null || !Number.isFinite(n) ? null : Number(n.toFixed(d));2324/** Mots génériques d'adresse ignorés lors de la recherche approximative. */25const STOP_WORDS = new Set([26 "rue", "avenue", "av", "boulevard", "boul", "blvd", "chemin", "ch", "route", "rte",27 "rang", "place", "montee", "montée", "impasse", "croissant", "terrasse", "allée", "allee",28 "cote", "côte", "carre", "carré", "du", "de", "des", "la", "le", "les", "l", "d",29 "au", "aux", "à", "a", "et", "app", "apt", "st", "ste",30]);3132function tokenize(q: string): string[] {33 return q34 .replace(/[^\p{L}\p{N}\s'-]/gu, " ")35 .trim()36 .split(/\s+/)37 .filter((t) => t.length > 0);38}3940function ftsAnd(tokens: string[], limit: number) {41 if (!tokens.length) return [];42 const match = tokens.map((t) => `"${t}"*`).join(" ");43 return getDb()44 .prepare(45 `SELECT u.*, bm25(units_fts) AS score46 FROM units_fts JOIN units u ON u.rowid = units_fts.rowid47 WHERE units_fts MATCH ? ORDER BY score LIMIT ?`48 )49 .all(match, limit) as ReturnType<typeof searchUnits>;50}5152/**53 * Recherche à relaxation progressive : exacte, puis sans mots génériques54 * (rue/avenue/de…), puis sans numéro civique, puis nom de voie seul.55 * Retourne toujours ce qui s'en rapproche le plus plutôt que rien.56 */57function searchFlexible(58 q: string,59 limit: number60): { rows: ReturnType<typeof searchUnits>; niveau: "exacte" | "approximative" } {61 const strict = searchUnits(q, limit);62 if (strict.length) return { rows: strict, niveau: "exacte" };6364 const tokens = tokenize(q);65 const sansStop = tokens.filter((t) => !STOP_WORDS.has(t.toLowerCase()));66 const sansNumero = sansStop.filter((t) => !/^\d+[a-z]?$/i.test(t));67 const nomsSeuls = sansNumero.filter((t) => t.length >= 4);6869 for (const attempt of [sansStop, sansNumero, nomsSeuls, nomsSeuls.slice(0, 1)]) {70 if (!attempt.length) continue;71 if (attempt.length === tokens.length) continue; // déjà tenté en strict72 const rows = ftsAnd(attempt, limit);73 if (rows.length) return { rows, niveau: "approximative" };74 }75 return { rows: [], niveau: "approximative" };76}7778/**79 * Résout un id éventuellement mal recopié par le modèle : essai brut,80 * puis version chiffres seulement (les id provinciaux sont numériques).81 */82function resolveId(raw: unknown): string {83 const a = String(raw ?? "").trim();84 if (getUnit(a)) return a;85 const d = a.replace(/\D/g, "");86 if (d && d !== a && getUnit(d)) return d;87 return a;88}8990const ID_ERR =91 "Plex introuvable — l'id est peut-être mal recopié. Relance chercher_plex et copie le champ `id` EXACTEMENT tel quel.";9293function liens(id: string) {94 const e = encodeURIComponent(id);95 return {96 fiche_complete: `https://www.valoplex.com/estimation/${e}`,97 rapport_standard_pdf: `https://www.valoplex.com/api/report?id=${e}`,98 rapport_professionnel_pdf: `https://www.valoplex.com/api/report/pro?id=${e}`,99 };100}101102function gabarit(portes: number | null): string {103 if (!portes) return "plex";104 if (portes === 2) return "duplex";105 if (portes === 3) return "triplex";106 if (portes === 4) return "quadruplex";107 if (portes === 5) return "quintuplex";108 if (portes === 6) return "sixplex";109 return `multi ${portes} portes`;110}111112function unitCard(u: ReturnType<typeof searchUnits>[number]) {113 return {114 id: u.id_provinc,115 adresse: [u.adresse, u.apt ? `app. ${u.apt}` : null].filter(Boolean).join(", "),116 municipalite: u.municipalite,117 arrondissement: u.arrond,118 portes: u.nb_logements,119 gabarit: gabarit(u.nb_logements),120 annee_construction: u.annee_construction,121 aire_habitable_m2: r(u.aire_etages_m2, 1),122 terrain_m2: r(u.superficie_terrain_m2),123 valeur_role_2026: u.valeur_role,124 };125}126127function evalCard(e: UnitEstimate) {128 const u = e.unit;129 const res = e.result;130 const portes = u?.nbLogements ?? null;131 return {132 plex: u133 ? {134 id: u.id,135 adresse: u.adresse,136 municipalite: u.municipalite,137 gabarit: gabarit(portes),138 portes,139 adresses_des_portes: u.specs.portesAdresses.slice(0, 20),140 annee_construction: u.anneeConstruction,141 aire_habitable_m2: r(u.aireEtagesM2, 1),142 terrain_m2: r(u.superficieTerrainM2),143 etages: u.specs.nbEtages,144 genre_construction: u.specs.genreConstruction,145 valeur_role_2026: u.valeurRole,146 valeur_terrain_role: u.specs.valeurTerrain,147 valeur_batiment_role: u.specs.valeurBatiment,148 liens: liens(u.id),149 }150 : null,151 estimation: {152 valeur: r(res.estimate),153 fourchette_basse_p10: r(res.low),154 fourchette_haute_p90: r(res.high),155 confiance_pct: res.confidencePct,156 confiance_niveau: res.confidenceLevel,157 part_modele_hedonique: r(res.modelEstimate),158 part_comparables: r(res.compsEstimate),159 poids_modele: r(res.modelWeight, 2),160 n_comparables_utilises: res.nCompsUsed,161 ecart_vs_role_pct: u?.valeurRole162 ? r((res.estimate / u.valeurRole - 1) * 100, 1)163 : null,164 },165 economie_par_porte: portes166 ? {167 valeur_par_porte: r(res.estimate / portes),168 role_par_porte: u?.valeurRole ? r(u.valeurRole / portes) : null,169 aire_par_porte_m2: u?.aireEtagesM2 ? r(u.aireEtagesM2 / portes, 1) : null,170 terrain_par_porte_m2: u?.superficieTerrainM2171 ? r(u.superficieTerrainM2 / portes, 1)172 : null,173 }174 : null,175 historique_2021_2026: u?.history ?? null,176 };177}178179/* ------------------------------------------------------------ tool defs */180181export const KA_TOOLS: Anthropic.Tool[] = [182 {183 name: "chercher_plex",184 description:185 "Recherche plein-texte d'un plex parmi les 393 867 immeubles de 2 logements et plus du Québec. Utilise-le dès qu'un utilisateur mentionne une adresse, même partielle. Si aucune correspondance exacte, l'outil retombe AUTOMATIQUEMENT sur des correspondances approximatives (même rue, numéros voisins) — le champ `correspondance` te le dit. Présente alors les candidats les plus proches au lieu de répondre introuvable. Plusieurs candidats plausibles → demande de préciser AVANT d'évaluer. Copie l'`id` EXACTEMENT tel que retourné.",186 input_schema: {187 type: "object",188 properties: {189 requete: { type: "string", description: "Adresse ou fragment, ex. « 2075 Grandjean Québec »" },190 limite: { type: "number", description: "Max résultats (défaut 6, max 12)" },191 },192 required: ["requete"],193 },194 },195 {196 name: "evaluer_plex",197 description:198 "Évaluation complète d'un plex par son id (obtenu via chercher_plex) : valeur estimée (modèle en ratio calibré par gabarit), fourchette P10-P90, confiance A-D, économie par porte (valeur/porte, rôle/porte, aire/porte), adresses de chaque porte, historique 2021-2026. L'outil central.",199 input_schema: {200 type: "object",201 properties: { id: { type: "string", description: "Identifiant provincial" } },202 required: ["id"],203 },204 },205 {206 name: "proforma_investisseur",207 description:208 "Pro forma investisseur complet d'un plex (approche revenu inversée avec tous les frais) : loyer implicite par porte, état des résultats (vacance, taxes, assurances, entretien, gestion…), RNE, TGA implicite, hypothèque canadienne, cashflow/porte/mois, DSCR, cash-on-cash, droits de mutation + notaire + inspection, liquidités requises, projection 5 ans (équité, gain total, multiple), loyer et taux de point mort, sensibilité aux taux ±1 %. Paramètres tous optionnels (défauts : taux 4,75 %, mise 25 %, amort. 25 ans, TGA par gabarit). Appelle-le pour toute question d'investissement, de rentabilité ou de financement.",209 input_schema: {210 type: "object",211 properties: {212 id: { type: "string", description: "Identifiant provincial du plex" },213 taux_hypo_pct: { type: "number", description: "Taux hypothécaire annuel, ex. 4.75" },214 mise_de_fonds_pct: { type: "number", description: "Mise de fonds en %, ex. 25" },215 amortissement_ans: { type: "number", description: "Amortissement en années, ex. 25" },216 tga_pct: { type: "number", description: "TGA imposé ; sinon référence par gabarit" },217 appreciation_pct: { type: "number", description: "Appréciation annuelle supposée, ex. 2.5" },218 },219 required: ["id"],220 },221 },222 {223 name: "comparables_detailles",224 description:225 "Ventes de plex comparables utilisées dans l'évaluation : adresse, date, prix payé, nombre de portes, distance, ajustements (marché, superficie, âge, portes) et poids. Pour « pourquoi ce prix ? » ou « qu'est-ce qui s'est vendu autour ? ».",226 input_schema: {227 type: "object",228 properties: {229 id: { type: "string" },230 max: { type: "number", description: "Max comparables (défaut 8)" },231 },232 required: ["id"],233 },234 },235 {236 name: "indice_marche_plex",237 description:238 "Tendance du marché des plex au Québec : indice mensuel $/m² lissé et croissance 12/24 mois. Pour contextualiser une évaluation ou un timing d'achat.",239 input_schema: { type: "object", properties: {} },240 },241 {242 name: "stats_municipalite",243 description:244 "Statistiques plex en direct d'une municipalité : nombre de plex et de portes, valeur totale/médiane 2026, valeur médiane par porte, répartition par gabarit (duplex, triplex…). Pour comparer un plex à son marché local.",245 input_schema: {246 type: "object",247 properties: { municipalite: { type: "string", description: "Nom exact, ex. « Trois-Rivières »" } },248 required: ["municipalite"],249 },250 },251 {252 name: "stats_provinciales",253 description:254 "Les grands chiffres des plex du Québec : valeur totale (400,6 G$), 393 867 plex, 1 733 744 portes, croissance 2021→2026, top municipalités. Pour les questions macro.",255 input_schema: { type: "object", properties: {} },256 },257 {258 name: "evaluer_parc",259 description:260 "Évalue un parc de plex (2 à 40 ids) : valeur totale, fourchette, confiance pondérée, portes totales, répartition par ville, croissance 2021→2026. Pour les investisseurs multi-immeubles.",261 input_schema: {262 type: "object",263 properties: { ids: { type: "array", items: { type: "string" } } },264 required: ["ids"],265 },266 },267 {268 name: "comparer_plex",269 description:270 "Compare 2 à 4 plex côte à côte : valeur, valeur/porte, écart vs rôle, confiance, croissance. Pour départager des occasions d'achat.",271 input_schema: {272 type: "object",273 properties: { ids: { type: "array", items: { type: "string" }, description: "2 à 4 ids" } },274 required: ["ids"],275 },276 },277 {278 name: "estimation_manuelle",279 description:280 "Estimation d'un plex sans adresse exacte : municipalité + nombre de portes, et si possible superficie, année. Seulement si chercher_plex ne trouve rien ou pour un scénario hypothétique.",281 input_schema: {282 type: "object",283 properties: {284 municipalite: { type: "string" },285 portes: { type: "number", description: "Nombre de logements (2+)" },286 aire_habitable_m2: { type: "number" },287 annee_construction: { type: "number" },288 terrain_m2: { type: "number" },289 },290 required: ["municipalite", "portes"],291 },292 },293 {294 name: "chercher_plex_secteur",295 description:296 "Trouve des plex dans une municipalité selon des critères d'investisseur : portes min/max, budget max (valeur estimée 2026), valeur min. Triés par valeur. Pour « trouve-moi un triplex à Trois-Rivières sous 700 k$ ».",297 input_schema: {298 type: "object",299 properties: {300 municipalite: { type: "string" },301 portes_min: { type: "number" },302 portes_max: { type: "number" },303 valeur_max: { type: "number", description: "Budget maximal ($)" },304 valeur_min: { type: "number" },305 limite: { type: "number", description: "Max résultats (défaut 8, max 15)" },306 },307 required: ["municipalite"],308 },309 },310 {311 name: "dossier_investisseur",312 description:313 "L'ARME LOURDE : dossier d'investisseur complet d'un plex en UN SEUL appel — évaluation + économie par porte + pro forma condensé (loyer implicite, RNE, cashflow/porte, DSCR, liquidités tout le kit, projection 5 ans, points morts) + marché local du gabarit (benchmark $/porte municipal) + tendance du marché des plex + top 5 comparables + synthèse chiffrée. Utilise-le SYSTÉMATIQUEMENT quand l'utilisateur demande d'évaluer ou d'analyser un plex identifié : tu obtiens tout pour livrer un mini-rapport d'investisseur d'un coup. Paramètres de financement optionnels.",314 input_schema: {315 type: "object",316 properties: {317 id: { type: "string", description: "Identifiant provincial du plex" },318 taux_hypo_pct: { type: "number" },319 mise_de_fonds_pct: { type: "number" },320 amortissement_ans: { type: "number" },321 tga_pct: { type: "number" },322 },323 required: ["id"],324 },325 },326 {327 name: "liens_rapports",328 description:329 "Liens de téléchargement des rapports PDF d'un plex (standard 3 pages avec pro forma, professionnel bancaire 6 pages) et lien de sa fiche. Offre-les à la fin d'une évaluation réussie.",330 input_schema: {331 type: "object",332 properties: { id: { type: "string" } },333 required: ["id"],334 },335 },336];337338/* ------------------------------------------------------------ execution */339340type J = Record<string, unknown>;341342function growth(idx: { month: string; idx: number }[], months: number): number | null {343 if (idx.length < months + 1) return null;344 const last = idx[idx.length - 1];345 const past = idx[idx.length - 1 - months];346 return past.idx > 0 ? r((last.idx / past.idx - 1) * 100, 1) : null;347}348349export function runKaTool(name: string, input: J): unknown {350 switch (name) {351 case "chercher_plex": {352 const limit = Math.min(Number(input.limite) || 6, 12);353 const { rows, niveau } = searchFlexible(String(input.requete ?? ""), limit);354 if (!rows.length)355 return {356 resultats: [],357 correspondance: "aucune",358 conseil:359 "Aucun plex trouvé, même approximatif. Rappel : ValoPlex ne couvre que les immeubles de 2 logements et plus (unifamiliale/condo → vrai-prix.com). Essayer une autre graphie ou estimation_manuelle.",360 };361 return {362 correspondance: niveau,363 ...(niveau === "approximative"364 ? {365 note: "Correspondance exacte introuvable — voici les plex les plus proches (même voie ou même secteur). Présente-les à l'utilisateur comme des suggestions.",366 }367 : {}),368 resultats: rows.map(unitCard),369 };370 }371372 case "evaluer_plex": {373 const e = estimateByUnitId(resolveId(input.id));374 if (!e) return { erreur: ID_ERR };375 return evalCard(e);376 }377378 case "proforma_investisseur": {379 const id = resolveId(input.id);380 const e = estimateByUnitId(id);381 if (!e || !e.unit) return { erreur: ID_ERR };382 const doors = e.unit.nbLogements ?? 2;383 const partial: Partial<ProformaParams> = {};384 if (input.taux_hypo_pct != null) partial.tauxHypoPct = Number(input.taux_hypo_pct);385 if (input.mise_de_fonds_pct != null) partial.miseDeFondsPct = Number(input.mise_de_fonds_pct);386 if (input.amortissement_ans != null) partial.amortAns = Number(input.amortissement_ans);387 if (input.tga_pct != null) partial.tgaPct = Number(input.tga_pct);388 if (input.appreciation_pct != null) partial.appreciationPct = Number(input.appreciation_pct);389 const pf = buildProforma(390 e.result.estimate,391 doors,392 e.unit.valeurRole,393 e.unit.municipalite,394 partial395 );396 return {397 plex: { adresse: e.unit.adresse, gabarit: gabarit(doors), portes: doors },398 valeur_utilisee: r(e.result.estimate),399 hypotheses: {400 taux_hypo_pct: pf.params.tauxHypoPct,401 mise_de_fonds_pct: pf.params.miseDeFondsPct,402 amortissement_ans: pf.params.amortAns,403 tga_pct: pf.params.tgaPct,404 tga_reference_gabarit_pct: pf.tgaRefPct,405 appreciation_pct: pf.params.appreciationPct,406 },407 revenus: {408 loyer_implicite_par_porte_mois: r(pf.loyerMoyenMensuel),409 revenus_bruts_annuels: r(pf.revenusBruts),410 vacance: r(pf.vacance),411 revenus_effectifs: r(pf.revenusEffectifs),412 },413 depenses_annuelles: pf.depenses.map((d) => ({ poste: d.key, montant: r(d.amount) })),414 exploitation: {415 rne_noi: r(pf.rne),416 ratio_depenses_pct: r(pf.ratioDepensesPct, 1),417 tga_implicite_pct: r(pf.tgaImplicitePct, 2),418 multiplicateur_revenus_bruts: r(pf.mrb, 1),419 },420 financement: {421 mise_de_fonds: r(pf.miseDeFonds),422 hypotheque: r(pf.hypotheque),423 paiement_mensuel: r(pf.paiementMensuelHypo),424 service_dette_annuel: r(pf.serviceDetteAnnuel),425 cashflow_annuel: r(pf.cashflowAnnuel),426 cashflow_par_porte_mois: r(pf.cashflowMensuelParPorte),427 dscr: r(pf.dscr, 2),428 },429 acquisition_tout_le_kit: {430 droits_mutation: r(pf.droitsMutation),431 notaire: r(pf.fraisNotaire),432 inspection: r(pf.fraisInspection),433 liquidites_requises_totales: r(pf.liquiditesRequises),434 cash_on_cash_pct: r(pf.cashOnCashPct, 1),435 },436 projection_5_ans: {437 capital_rembourse_an_1: r(pf.capitalAn1),438 rendement_total_an_1_pct: r(pf.rendementTotalAn1Pct, 1),439 valeur_projetee: r(pf.valeur5Ans),440 equite: r(pf.equite5Ans),441 gain_total: r(pf.gainTotal5Ans),442 multiple_sur_liquidites: r(pf.multipleLiquidites5Ans, 2),443 },444 marges_de_securite: {445 loyer_point_mort_par_porte: r(pf.loyerPointMort),446 marge_loyer_pct: r(pf.margeSecuriteLoyerPct, 1),447 taux_point_mort_pct: r(pf.tauxPointMortPct, 2),448 sensibilite_taux: pf.sensibiliteTaux.map((s) => ({449 taux_pct: s.tauxPct,450 cashflow_annuel: r(s.cashflowAnnuel),451 dscr: r(s.dscr, 2),452 })),453 },454 };455 }456457 case "comparables_detailles": {458 const e = estimateByUnitId(resolveId(input.id));459 if (!e) return { erreur: ID_ERR };460 const max = Math.min(Number(input.max) || 8, 15);461 if (!e.result.comps.length)462 return {463 n_utilises: 0,464 note: "Aucune vente de plex comparable dans le secteur — l'estimation repose sur le modèle hédonique.",465 };466 return {467 n_utilises: e.result.nCompsUsed,468 comparables: e.result.comps.slice(0, max).map((c) => ({469 adresse: [c.street, c.city].filter(Boolean).join(", "),470 vendu_le: c.date,471 prix_paye: c.amount,472 portes: c.doors ?? null,473 distance_m: r(c.distanceM),474 ajust_marche: r(c.adjTime),475 ajust_superficie: r(c.adjArea),476 ajust_age: r(c.adjAge),477 ajust_portes: r(c.adjDoors),478 prix_ajuste: r(c.adjustedPrice),479 poids_pct: r(c.weight * 100, 1),480 })),481 };482 }483484 case "indice_marche_plex": {485 const idx = getMarketIndex("plex");486 const last12 = idx.slice(-13);487 return {488 croissance_12_mois_pct: growth(idx, 12),489 croissance_24_mois_pct: growth(idx, 24),490 serie_12_derniers_mois: last12.map((p) => ({ mois: p.month, indice: r(p.idx, 3) })),491 note: "Indice $/m² des ventes de plex, lissé 3 mois, 1.0 = niveau actuel.",492 };493 }494495 case "stats_municipalite": {496 const mun = String(input.municipalite ?? "");497 const g = getDb()498 .prepare(499 `SELECT COUNT(*) n, SUM(nb_logements) portes, SUM(est_2026) total500 FROM units WHERE municipalite = ? COLLATE NOCASE`501 )502 .get(mun) as { n: number; portes: number | null; total: number | null };503 if (!g?.n) return { erreur: `Municipalité « ${mun} » introuvable (nom exact requis).` };504 const med = getDb()505 .prepare(506 `SELECT est_2026 v, nb_logements p FROM units507 WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL508 ORDER BY est_2026 LIMIT 1509 OFFSET (SELECT COUNT(*) FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL) / 2`510 )511 .get(mun, mun) as { v: number; p: number | null } | undefined;512 const gabarits = getDb()513 .prepare(514 `SELECT CASE WHEN nb_logements = 2 THEN 'duplex'515 WHEN nb_logements = 3 THEN 'triplex'516 WHEN nb_logements BETWEEN 4 AND 5 THEN '4-5 portes'517 WHEN nb_logements BETWEEN 6 AND 12 THEN '6-12 portes'518 ELSE '13+ portes' END AS gab,519 COUNT(*) n, SUM(est_2026) total, AVG(est_2026 / nb_logements) prix_porte_moyen520 FROM units WHERE municipalite = ? COLLATE NOCASE AND nb_logements >= 2521 GROUP BY gab ORDER BY n DESC`522 )523 .all(mun) as { gab: string; n: number; total: number | null; prix_porte_moyen: number | null }[];524 return {525 municipalite: mun,526 plex: g.n,527 portes: g.portes,528 valeur_totale_estimee_2026: r(g.total),529 valeur_mediane_2026: med?.v ?? null,530 par_gabarit: gabarits.map((t) => ({531 gabarit: t.gab,532 plex: t.n,533 total: r(t.total),534 valeur_moyenne_par_porte: r(t.prix_porte_moyen),535 })),536 };537 }538539 case "stats_provinciales": {540 const s = stats as J;541 const villes = (s.par_ville as { ville: string; total: number; n: number }[] | undefined)?.slice(0, 10);542 return {543 valeur_totale_plex_quebec_2026: s.valeur_totale_2026,544 valeur_role_totale: s.valeur_role_totale,545 plex: s.unites,546 portes: s.logements,547 valeur_mediane_2026: s.valeur_mediane_2026,548 croissance_2021_2026_pct: s.croissance_2021_2026_pct,549 municipalites: s.municipalites,550 par_gabarit: s.par_type,551 top_10_villes: villes,552 };553 }554555 case "evaluer_parc": {556 const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 40).map(resolveId);557 if (ids.length < 1) return { erreur: "Fournir au moins un id." };558 const p = estimatePortfolio(ids);559 if (!p.items.length) return { erreur: "Aucun plex valide trouvé." };560 const a = p.aggregates;561 return {562 plex_evalues: a.count,563 valeur_totale: r(a.totalEstimate),564 fourchette: { basse: r(a.totalLow), haute: r(a.totalHigh) },565 valeur_role_totale: r(a.totalRole),566 ecart_vs_role_pct: r(a.ecartRolePct, 1),567 confiance: { pct: a.confidencePct, niveau: a.confidenceLevel },568 portes_totales: a.totalDwellings,569 valeur_par_porte_parc:570 a.totalDwellings > 0 ? r(a.totalEstimate / a.totalDwellings) : null,571 croissance_parc_2021_2026_pct: r(a.growthPct, 1),572 par_ville: a.municipalities,573 detail: p.items.map((i) => ({574 id: i.unit!.id,575 adresse: i.unit!.adresse,576 portes: i.unit!.nbLogements,577 valeur: r(i.result.estimate),578 confiance: i.result.confidenceLevel,579 })),580 };581 }582583 case "comparer_plex": {584 const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 4).map(resolveId);585 if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." };586 return {587 comparaison: ids.map((id) => {588 const e = estimateByUnitId(id);589 if (!e || !e.unit) return { id, erreur: "introuvable" };590 const portes = e.unit.nbLogements;591 const h21 = e.unit.history.find((h) => h.year === 2021)?.value;592 const h26 = e.unit.history.find((h) => h.year === 2026)?.value;593 return {594 id,595 adresse: e.unit.adresse,596 municipalite: e.unit.municipalite,597 gabarit: gabarit(portes),598 valeur: r(e.result.estimate),599 valeur_par_porte: portes ? r(e.result.estimate / portes) : null,600 ecart_vs_role_pct: e.unit.valeurRole601 ? r((e.result.estimate / e.unit.valeurRole - 1) * 100, 1)602 : null,603 confiance: e.result.confidenceLevel,604 croissance_2021_2026_pct: h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null,605 };606 }),607 };608 }609610 case "estimation_manuelle": {611 const e = estimateManual({612 municipality: String(input.municipalite ?? ""),613 typeProp: "plex",614 portes: input.portes ? Number(input.portes) : undefined,615 floorArea: input.aire_habitable_m2 ? Number(input.aire_habitable_m2) : undefined,616 yearBuilt: input.annee_construction ? Number(input.annee_construction) : undefined,617 landArea: input.terrain_m2 ? Number(input.terrain_m2) : undefined,618 });619 if (!e) return { erreur: "Municipalité inconnue ou marché trop mince." };620 return evalCard(e);621 }622623 case "chercher_plex_secteur": {624 const mun = String(input.municipalite ?? "");625 const limit = Math.min(Number(input.limite) || 8, 15);626 const conds: string[] = ["municipalite = ? COLLATE NOCASE", "est_2026 IS NOT NULL"];627 const args: unknown[] = [mun];628 if (input.portes_min) {629 conds.push("nb_logements >= ?");630 args.push(Number(input.portes_min));631 }632 if (input.portes_max) {633 conds.push("nb_logements <= ?");634 args.push(Number(input.portes_max));635 }636 if (input.valeur_max) {637 conds.push("est_2026 <= ?");638 args.push(Number(input.valeur_max));639 }640 if (input.valeur_min) {641 conds.push("est_2026 >= ?");642 args.push(Number(input.valeur_min));643 }644 const rows = getDb()645 .prepare(646 `SELECT * FROM units WHERE ${conds.join(" AND ")}647 ORDER BY est_2026 DESC LIMIT ?`648 )649 .all(...args, limit) as Parameters<typeof unitCard>[0][];650 if (!rows.length) return { resultats: [], note: "Aucun plex ne correspond aux critères." };651 return {652 resultats: rows.map((u) => ({653 ...unitCard(u),654 valeur_estimee_2026: r(u.est_2026),655 valeur_par_porte: u.nb_logements ? r((u.est_2026 ?? 0) / u.nb_logements) : null,656 })),657 };658 }659660 case "dossier_investisseur": {661 const id = resolveId(input.id);662 const ev = estimateByUnitId(id);663 if (!ev || !ev.unit) return { erreur: "Plex introuvable — vérifier l'id avec chercher_plex." };664 const u = ev.unit;665 const res = ev.result;666 const doors = u.nbLogements ?? 2;667668 const pfFull = runKaTool("proforma_investisseur", {669 id,670 taux_hypo_pct: input.taux_hypo_pct,671 mise_de_fonds_pct: input.mise_de_fonds_pct,672 amortissement_ans: input.amortissement_ans,673 tga_pct: input.tga_pct,674 }) as Record<string, unknown>;675 const proformaCondense = {676 hypotheses: pfFull.hypotheses,677 loyer_implicite_par_porte_mois: (pfFull.revenus as Record<string, unknown>)?.loyer_implicite_par_porte_mois,678 rne_noi: (pfFull.exploitation as Record<string, unknown>)?.rne_noi,679 tga_implicite_pct: (pfFull.exploitation as Record<string, unknown>)?.tga_implicite_pct,680 cashflow_par_porte_mois: (pfFull.financement as Record<string, unknown>)?.cashflow_par_porte_mois,681 dscr: (pfFull.financement as Record<string, unknown>)?.dscr,682 liquidites_requises_totales: (pfFull.acquisition_tout_le_kit as Record<string, unknown>)?.liquidites_requises_totales,683 cash_on_cash_pct: (pfFull.acquisition_tout_le_kit as Record<string, unknown>)?.cash_on_cash_pct,684 projection_5_ans: pfFull.projection_5_ans,685 marges_de_securite: {686 loyer_point_mort_par_porte: (pfFull.marges_de_securite as Record<string, unknown>)?.loyer_point_mort_par_porte,687 taux_point_mort_pct: (pfFull.marges_de_securite as Record<string, unknown>)?.taux_point_mort_pct,688 },689 };690691 // benchmark $/porte du gabarit dans la municipalité692 const bandCond =693 doors <= 3 ? "nb_logements = ?" : doors <= 5 ? "nb_logements BETWEEN 4 AND 5" : doors <= 12 ? "nb_logements BETWEEN 6 AND 12" : "nb_logements >= 13";694 const bandArgs: unknown[] = doors <= 3 ? [u.municipalite, doors] : [u.municipalite];695 const bench = u.municipalite696 ? (getDb()697 .prepare(698 `SELECT COUNT(*) n, AVG(est_2026 / nb_logements) prix_porte_moyen699 FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL AND ${bandCond}`700 )701 .get(...bandArgs) as { n: number; prix_porte_moyen: number | null })702 : null;703704 const idx = getMarketIndex("plex");705 const comps = res.comps.slice(0, 5).map((c) => ({706 adresse: [c.street, c.city].filter(Boolean).join(", "),707 vendu_le: c.date,708 prix_paye: c.amount,709 portes: c.doors ?? null,710 prix_ajuste: r(c.adjustedPrice),711 poids_pct: r(c.weight * 100, 1),712 }));713714 const h21 = u.history.find((h) => h.year === 2021)?.value;715 const h26 = u.history.find((h) => h.year === 2026)?.value;716 const valeurParPorte = r(res.estimate / doors);717 const benchPorte = bench?.prix_porte_moyen ? r(bench.prix_porte_moyen) : null;718719 return {720 evaluation: evalCard(ev),721 proforma: proformaCondense,722 marche_local_du_gabarit: bench723 ? {724 municipalite: u.municipalite,725 plex_comparables_dans_la_ville: bench.n,726 valeur_moyenne_par_porte: benchPorte,727 }728 : null,729 tendance_marche_plex: {730 croissance_12_mois_pct: growth(idx, 12),731 croissance_24_mois_pct: growth(idx, 24),732 },733 top_comparables: comps,734 synthese: {735 valeur_par_porte: valeurParPorte,736 ecart_par_porte_vs_ville_pct:737 valeurParPorte && benchPorte ? r((valeurParPorte / benchPorte - 1) * 100, 1) : null,738 croissance_plex_2021_2026_pct: h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null,739 note: "Le pro forma part du loyer implicite (supposé par la valeur au TGA de référence), pas des baux réels.",740 },741 };742 }743744 case "liens_rapports": {745 const id = resolveId(input.id);746 const u = getUnit(id);747 if (!u) return { erreur: ID_ERR };748 return {749 ...liens(id),750 note: "Le rapport standard inclut le pro forma ; le professionnel (6 pages) est le format bancaire.",751 };752 }753754 default:755 return { erreur: `Outil inconnu : ${name}` };756 }757}758759export { DEFAULT_PARAMS, tgaReference };760