// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Ka — la panoplie d'outils de l'agent IA de ValoPlex. * Spécialisée plex : évaluation en ratio, économie par porte, pro forma * investisseur « tout le kit ». Exécutée côté serveur sur la base SQLite * (393 867 plex) et le moteur d'estimation. */ import Anthropic from "@anthropic-ai/sdk"; import { getDb, getMarketIndex, getUnit, searchUnits } from "../db"; import { estimateByUnitId, estimateManual, estimatePortfolio, type UnitEstimate, } from "../estimator"; import { buildProforma, DEFAULT_PARAMS, tgaReference, type ProformaParams } from "../proforma"; import stats from "../../data/stats.json"; /* ---------------------------------------------------------------- helpers */ const r = (n: number | null | undefined, d = 0): number | null => n == null || !Number.isFinite(n) ? null : Number(n.toFixed(d)); /** Mots génériques d'adresse ignorés lors de la recherche approximative. */ const STOP_WORDS = new Set([ "rue", "avenue", "av", "boulevard", "boul", "blvd", "chemin", "ch", "route", "rte", "rang", "place", "montee", "montée", "impasse", "croissant", "terrasse", "allée", "allee", "cote", "côte", "carre", "carré", "du", "de", "des", "la", "le", "les", "l", "d", "au", "aux", "à", "a", "et", "app", "apt", "st", "ste", ]); function tokenize(q: string): string[] { return q .replace(/[^\p{L}\p{N}\s'-]/gu, " ") .trim() .split(/\s+/) .filter((t) => t.length > 0); } function ftsAnd(tokens: string[], limit: number) { if (!tokens.length) return []; const match = tokens.map((t) => `"${t}"*`).join(" "); return getDb() .prepare( `SELECT u.*, bm25(units_fts) AS score FROM units_fts JOIN units u ON u.rowid = units_fts.rowid WHERE units_fts MATCH ? ORDER BY score LIMIT ?` ) .all(match, limit) as ReturnType; } /** * Recherche à relaxation progressive : exacte, puis sans mots génériques * (rue/avenue/de…), puis sans numéro civique, puis nom de voie seul. * Retourne toujours ce qui s'en rapproche le plus plutôt que rien. */ function searchFlexible( q: string, limit: number ): { rows: ReturnType; niveau: "exacte" | "approximative" } { const strict = searchUnits(q, limit); if (strict.length) return { rows: strict, niveau: "exacte" }; const tokens = tokenize(q); const sansStop = tokens.filter((t) => !STOP_WORDS.has(t.toLowerCase())); const sansNumero = sansStop.filter((t) => !/^\d+[a-z]?$/i.test(t)); const nomsSeuls = sansNumero.filter((t) => t.length >= 4); for (const attempt of [sansStop, sansNumero, nomsSeuls, nomsSeuls.slice(0, 1)]) { if (!attempt.length) continue; if (attempt.length === tokens.length) continue; // déjà tenté en strict const rows = ftsAnd(attempt, limit); if (rows.length) return { rows, niveau: "approximative" }; } return { rows: [], niveau: "approximative" }; } /** * Résout un id éventuellement mal recopié par le modèle : essai brut, * puis version chiffres seulement (les id provinciaux sont numériques). */ function resolveId(raw: unknown): string { const a = String(raw ?? "").trim(); if (getUnit(a)) return a; const d = a.replace(/\D/g, ""); if (d && d !== a && getUnit(d)) return d; return a; } const ID_ERR = "Plex introuvable — l'id est peut-être mal recopié. Relance chercher_plex et copie le champ `id` EXACTEMENT tel quel."; function liens(id: string) { const e = encodeURIComponent(id); return { fiche_complete: `https://www.valoplex.com/estimation/${e}`, rapport_standard_pdf: `https://www.valoplex.com/api/report?id=${e}`, rapport_professionnel_pdf: `https://www.valoplex.com/api/report/pro?id=${e}`, }; } function gabarit(portes: number | null): string { if (!portes) return "plex"; if (portes === 2) return "duplex"; if (portes === 3) return "triplex"; if (portes === 4) return "quadruplex"; if (portes === 5) return "quintuplex"; if (portes === 6) return "sixplex"; return `multi ${portes} portes`; } function unitCard(u: ReturnType[number]) { return { id: u.id_provinc, adresse: [u.adresse, u.apt ? `app. ${u.apt}` : null].filter(Boolean).join(", "), municipalite: u.municipalite, arrondissement: u.arrond, portes: u.nb_logements, gabarit: gabarit(u.nb_logements), annee_construction: u.annee_construction, aire_habitable_m2: r(u.aire_etages_m2, 1), terrain_m2: r(u.superficie_terrain_m2), valeur_role_2026: u.valeur_role, }; } function evalCard(e: UnitEstimate) { const u = e.unit; const res = e.result; const portes = u?.nbLogements ?? null; return { plex: u ? { id: u.id, adresse: u.adresse, municipalite: u.municipalite, gabarit: gabarit(portes), portes, adresses_des_portes: u.specs.portesAdresses.slice(0, 20), annee_construction: u.anneeConstruction, aire_habitable_m2: r(u.aireEtagesM2, 1), terrain_m2: r(u.superficieTerrainM2), etages: u.specs.nbEtages, genre_construction: u.specs.genreConstruction, valeur_role_2026: u.valeurRole, valeur_terrain_role: u.specs.valeurTerrain, valeur_batiment_role: u.specs.valeurBatiment, liens: liens(u.id), } : null, estimation: { valeur: r(res.estimate), fourchette_basse_p10: r(res.low), fourchette_haute_p90: r(res.high), confiance_pct: res.confidencePct, confiance_niveau: res.confidenceLevel, part_modele_hedonique: r(res.modelEstimate), part_comparables: r(res.compsEstimate), poids_modele: r(res.modelWeight, 2), n_comparables_utilises: res.nCompsUsed, ecart_vs_role_pct: u?.valeurRole ? r((res.estimate / u.valeurRole - 1) * 100, 1) : null, }, economie_par_porte: portes ? { valeur_par_porte: r(res.estimate / portes), role_par_porte: u?.valeurRole ? r(u.valeurRole / portes) : null, aire_par_porte_m2: u?.aireEtagesM2 ? r(u.aireEtagesM2 / portes, 1) : null, terrain_par_porte_m2: u?.superficieTerrainM2 ? r(u.superficieTerrainM2 / portes, 1) : null, } : null, historique_2021_2026: u?.history ?? null, }; } /* ------------------------------------------------------------ tool defs */ export const KA_TOOLS: Anthropic.Tool[] = [ { name: "chercher_plex", description: "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é.", input_schema: { type: "object", properties: { requete: { type: "string", description: "Adresse ou fragment, ex. « 2075 Grandjean Québec »" }, limite: { type: "number", description: "Max résultats (défaut 6, max 12)" }, }, required: ["requete"], }, }, { name: "evaluer_plex", description: "É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.", input_schema: { type: "object", properties: { id: { type: "string", description: "Identifiant provincial" } }, required: ["id"], }, }, { name: "proforma_investisseur", description: "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.", input_schema: { type: "object", properties: { id: { type: "string", description: "Identifiant provincial du plex" }, taux_hypo_pct: { type: "number", description: "Taux hypothécaire annuel, ex. 4.75" }, mise_de_fonds_pct: { type: "number", description: "Mise de fonds en %, ex. 25" }, amortissement_ans: { type: "number", description: "Amortissement en années, ex. 25" }, tga_pct: { type: "number", description: "TGA imposé ; sinon référence par gabarit" }, appreciation_pct: { type: "number", description: "Appréciation annuelle supposée, ex. 2.5" }, }, required: ["id"], }, }, { name: "comparables_detailles", description: "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 ? ».", input_schema: { type: "object", properties: { id: { type: "string" }, max: { type: "number", description: "Max comparables (défaut 8)" }, }, required: ["id"], }, }, { name: "indice_marche_plex", description: "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.", input_schema: { type: "object", properties: {} }, }, { name: "stats_municipalite", description: "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.", input_schema: { type: "object", properties: { municipalite: { type: "string", description: "Nom exact, ex. « Trois-Rivières »" } }, required: ["municipalite"], }, }, { name: "stats_provinciales", description: "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.", input_schema: { type: "object", properties: {} }, }, { name: "evaluer_parc", description: "É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.", input_schema: { type: "object", properties: { ids: { type: "array", items: { type: "string" } } }, required: ["ids"], }, }, { name: "comparer_plex", description: "Compare 2 à 4 plex côte à côte : valeur, valeur/porte, écart vs rôle, confiance, croissance. Pour départager des occasions d'achat.", input_schema: { type: "object", properties: { ids: { type: "array", items: { type: "string" }, description: "2 à 4 ids" } }, required: ["ids"], }, }, { name: "estimation_manuelle", description: "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.", input_schema: { type: "object", properties: { municipalite: { type: "string" }, portes: { type: "number", description: "Nombre de logements (2+)" }, aire_habitable_m2: { type: "number" }, annee_construction: { type: "number" }, terrain_m2: { type: "number" }, }, required: ["municipalite", "portes"], }, }, { name: "chercher_plex_secteur", description: "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$ ».", input_schema: { type: "object", properties: { municipalite: { type: "string" }, portes_min: { type: "number" }, portes_max: { type: "number" }, valeur_max: { type: "number", description: "Budget maximal ($)" }, valeur_min: { type: "number" }, limite: { type: "number", description: "Max résultats (défaut 8, max 15)" }, }, required: ["municipalite"], }, }, { name: "dossier_investisseur", description: "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.", input_schema: { type: "object", properties: { id: { type: "string", description: "Identifiant provincial du plex" }, taux_hypo_pct: { type: "number" }, mise_de_fonds_pct: { type: "number" }, amortissement_ans: { type: "number" }, tga_pct: { type: "number" }, }, required: ["id"], }, }, { name: "liens_rapports", description: "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.", input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], }, }, ]; /* ------------------------------------------------------------ execution */ type J = Record; function growth(idx: { month: string; idx: number }[], months: number): number | null { if (idx.length < months + 1) return null; const last = idx[idx.length - 1]; const past = idx[idx.length - 1 - months]; return past.idx > 0 ? r((last.idx / past.idx - 1) * 100, 1) : null; } export function runKaTool(name: string, input: J): unknown { switch (name) { case "chercher_plex": { const limit = Math.min(Number(input.limite) || 6, 12); const { rows, niveau } = searchFlexible(String(input.requete ?? ""), limit); if (!rows.length) return { resultats: [], correspondance: "aucune", conseil: "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.", }; return { correspondance: niveau, ...(niveau === "approximative" ? { 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.", } : {}), resultats: rows.map(unitCard), }; } case "evaluer_plex": { const e = estimateByUnitId(resolveId(input.id)); if (!e) return { erreur: ID_ERR }; return evalCard(e); } case "proforma_investisseur": { const id = resolveId(input.id); const e = estimateByUnitId(id); if (!e || !e.unit) return { erreur: ID_ERR }; const doors = e.unit.nbLogements ?? 2; const partial: Partial = {}; if (input.taux_hypo_pct != null) partial.tauxHypoPct = Number(input.taux_hypo_pct); if (input.mise_de_fonds_pct != null) partial.miseDeFondsPct = Number(input.mise_de_fonds_pct); if (input.amortissement_ans != null) partial.amortAns = Number(input.amortissement_ans); if (input.tga_pct != null) partial.tgaPct = Number(input.tga_pct); if (input.appreciation_pct != null) partial.appreciationPct = Number(input.appreciation_pct); const pf = buildProforma( e.result.estimate, doors, e.unit.valeurRole, e.unit.municipalite, partial ); return { plex: { adresse: e.unit.adresse, gabarit: gabarit(doors), portes: doors }, valeur_utilisee: r(e.result.estimate), hypotheses: { taux_hypo_pct: pf.params.tauxHypoPct, mise_de_fonds_pct: pf.params.miseDeFondsPct, amortissement_ans: pf.params.amortAns, tga_pct: pf.params.tgaPct, tga_reference_gabarit_pct: pf.tgaRefPct, appreciation_pct: pf.params.appreciationPct, }, revenus: { loyer_implicite_par_porte_mois: r(pf.loyerMoyenMensuel), revenus_bruts_annuels: r(pf.revenusBruts), vacance: r(pf.vacance), revenus_effectifs: r(pf.revenusEffectifs), }, depenses_annuelles: pf.depenses.map((d) => ({ poste: d.key, montant: r(d.amount) })), exploitation: { rne_noi: r(pf.rne), ratio_depenses_pct: r(pf.ratioDepensesPct, 1), tga_implicite_pct: r(pf.tgaImplicitePct, 2), multiplicateur_revenus_bruts: r(pf.mrb, 1), }, financement: { mise_de_fonds: r(pf.miseDeFonds), hypotheque: r(pf.hypotheque), paiement_mensuel: r(pf.paiementMensuelHypo), service_dette_annuel: r(pf.serviceDetteAnnuel), cashflow_annuel: r(pf.cashflowAnnuel), cashflow_par_porte_mois: r(pf.cashflowMensuelParPorte), dscr: r(pf.dscr, 2), }, acquisition_tout_le_kit: { droits_mutation: r(pf.droitsMutation), notaire: r(pf.fraisNotaire), inspection: r(pf.fraisInspection), liquidites_requises_totales: r(pf.liquiditesRequises), cash_on_cash_pct: r(pf.cashOnCashPct, 1), }, projection_5_ans: { capital_rembourse_an_1: r(pf.capitalAn1), rendement_total_an_1_pct: r(pf.rendementTotalAn1Pct, 1), valeur_projetee: r(pf.valeur5Ans), equite: r(pf.equite5Ans), gain_total: r(pf.gainTotal5Ans), multiple_sur_liquidites: r(pf.multipleLiquidites5Ans, 2), }, marges_de_securite: { loyer_point_mort_par_porte: r(pf.loyerPointMort), marge_loyer_pct: r(pf.margeSecuriteLoyerPct, 1), taux_point_mort_pct: r(pf.tauxPointMortPct, 2), sensibilite_taux: pf.sensibiliteTaux.map((s) => ({ taux_pct: s.tauxPct, cashflow_annuel: r(s.cashflowAnnuel), dscr: r(s.dscr, 2), })), }, }; } case "comparables_detailles": { const e = estimateByUnitId(resolveId(input.id)); if (!e) return { erreur: ID_ERR }; const max = Math.min(Number(input.max) || 8, 15); if (!e.result.comps.length) return { n_utilises: 0, note: "Aucune vente de plex comparable dans le secteur — l'estimation repose sur le modèle hédonique.", }; return { n_utilises: e.result.nCompsUsed, comparables: e.result.comps.slice(0, max).map((c) => ({ adresse: [c.street, c.city].filter(Boolean).join(", "), vendu_le: c.date, prix_paye: c.amount, portes: c.doors ?? null, distance_m: r(c.distanceM), ajust_marche: r(c.adjTime), ajust_superficie: r(c.adjArea), ajust_age: r(c.adjAge), ajust_portes: r(c.adjDoors), prix_ajuste: r(c.adjustedPrice), poids_pct: r(c.weight * 100, 1), })), }; } case "indice_marche_plex": { const idx = getMarketIndex("plex"); const last12 = idx.slice(-13); return { croissance_12_mois_pct: growth(idx, 12), croissance_24_mois_pct: growth(idx, 24), serie_12_derniers_mois: last12.map((p) => ({ mois: p.month, indice: r(p.idx, 3) })), note: "Indice $/m² des ventes de plex, lissé 3 mois, 1.0 = niveau actuel.", }; } case "stats_municipalite": { const mun = String(input.municipalite ?? ""); const g = getDb() .prepare( `SELECT COUNT(*) n, SUM(nb_logements) portes, SUM(est_2026) total FROM units WHERE municipalite = ? COLLATE NOCASE` ) .get(mun) as { n: number; portes: number | null; total: number | null }; if (!g?.n) return { erreur: `Municipalité « ${mun} » introuvable (nom exact requis).` }; const med = getDb() .prepare( `SELECT est_2026 v, nb_logements p FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL ORDER BY est_2026 LIMIT 1 OFFSET (SELECT COUNT(*) FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL) / 2` ) .get(mun, mun) as { v: number; p: number | null } | undefined; const gabarits = getDb() .prepare( `SELECT CASE WHEN nb_logements = 2 THEN 'duplex' WHEN nb_logements = 3 THEN 'triplex' WHEN nb_logements BETWEEN 4 AND 5 THEN '4-5 portes' WHEN nb_logements BETWEEN 6 AND 12 THEN '6-12 portes' ELSE '13+ portes' END AS gab, COUNT(*) n, SUM(est_2026) total, AVG(est_2026 / nb_logements) prix_porte_moyen FROM units WHERE municipalite = ? COLLATE NOCASE AND nb_logements >= 2 GROUP BY gab ORDER BY n DESC` ) .all(mun) as { gab: string; n: number; total: number | null; prix_porte_moyen: number | null }[]; return { municipalite: mun, plex: g.n, portes: g.portes, valeur_totale_estimee_2026: r(g.total), valeur_mediane_2026: med?.v ?? null, par_gabarit: gabarits.map((t) => ({ gabarit: t.gab, plex: t.n, total: r(t.total), valeur_moyenne_par_porte: r(t.prix_porte_moyen), })), }; } case "stats_provinciales": { const s = stats as J; const villes = (s.par_ville as { ville: string; total: number; n: number }[] | undefined)?.slice(0, 10); return { valeur_totale_plex_quebec_2026: s.valeur_totale_2026, valeur_role_totale: s.valeur_role_totale, plex: s.unites, portes: s.logements, valeur_mediane_2026: s.valeur_mediane_2026, croissance_2021_2026_pct: s.croissance_2021_2026_pct, municipalites: s.municipalites, par_gabarit: s.par_type, top_10_villes: villes, }; } case "evaluer_parc": { const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 40).map(resolveId); if (ids.length < 1) return { erreur: "Fournir au moins un id." }; const p = estimatePortfolio(ids); if (!p.items.length) return { erreur: "Aucun plex valide trouvé." }; const a = p.aggregates; return { plex_evalues: a.count, valeur_totale: r(a.totalEstimate), fourchette: { basse: r(a.totalLow), haute: r(a.totalHigh) }, valeur_role_totale: r(a.totalRole), ecart_vs_role_pct: r(a.ecartRolePct, 1), confiance: { pct: a.confidencePct, niveau: a.confidenceLevel }, portes_totales: a.totalDwellings, valeur_par_porte_parc: a.totalDwellings > 0 ? r(a.totalEstimate / a.totalDwellings) : null, croissance_parc_2021_2026_pct: r(a.growthPct, 1), par_ville: a.municipalities, detail: p.items.map((i) => ({ id: i.unit!.id, adresse: i.unit!.adresse, portes: i.unit!.nbLogements, valeur: r(i.result.estimate), confiance: i.result.confidenceLevel, })), }; } case "comparer_plex": { const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 4).map(resolveId); if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." }; return { comparaison: ids.map((id) => { const e = estimateByUnitId(id); if (!e || !e.unit) return { id, erreur: "introuvable" }; const portes = e.unit.nbLogements; const h21 = e.unit.history.find((h) => h.year === 2021)?.value; const h26 = e.unit.history.find((h) => h.year === 2026)?.value; return { id, adresse: e.unit.adresse, municipalite: e.unit.municipalite, gabarit: gabarit(portes), valeur: r(e.result.estimate), valeur_par_porte: portes ? r(e.result.estimate / portes) : null, ecart_vs_role_pct: e.unit.valeurRole ? r((e.result.estimate / e.unit.valeurRole - 1) * 100, 1) : null, confiance: e.result.confidenceLevel, croissance_2021_2026_pct: h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null, }; }), }; } case "estimation_manuelle": { const e = estimateManual({ municipality: String(input.municipalite ?? ""), typeProp: "plex", portes: input.portes ? Number(input.portes) : undefined, floorArea: input.aire_habitable_m2 ? Number(input.aire_habitable_m2) : undefined, yearBuilt: input.annee_construction ? Number(input.annee_construction) : undefined, landArea: input.terrain_m2 ? Number(input.terrain_m2) : undefined, }); if (!e) return { erreur: "Municipalité inconnue ou marché trop mince." }; return evalCard(e); } case "chercher_plex_secteur": { const mun = String(input.municipalite ?? ""); const limit = Math.min(Number(input.limite) || 8, 15); const conds: string[] = ["municipalite = ? COLLATE NOCASE", "est_2026 IS NOT NULL"]; const args: unknown[] = [mun]; if (input.portes_min) { conds.push("nb_logements >= ?"); args.push(Number(input.portes_min)); } if (input.portes_max) { conds.push("nb_logements <= ?"); args.push(Number(input.portes_max)); } if (input.valeur_max) { conds.push("est_2026 <= ?"); args.push(Number(input.valeur_max)); } if (input.valeur_min) { conds.push("est_2026 >= ?"); args.push(Number(input.valeur_min)); } const rows = getDb() .prepare( `SELECT * FROM units WHERE ${conds.join(" AND ")} ORDER BY est_2026 DESC LIMIT ?` ) .all(...args, limit) as Parameters[0][]; if (!rows.length) return { resultats: [], note: "Aucun plex ne correspond aux critères." }; return { resultats: rows.map((u) => ({ ...unitCard(u), valeur_estimee_2026: r(u.est_2026), valeur_par_porte: u.nb_logements ? r((u.est_2026 ?? 0) / u.nb_logements) : null, })), }; } case "dossier_investisseur": { const id = resolveId(input.id); const ev = estimateByUnitId(id); if (!ev || !ev.unit) return { erreur: "Plex introuvable — vérifier l'id avec chercher_plex." }; const u = ev.unit; const res = ev.result; const doors = u.nbLogements ?? 2; const pfFull = runKaTool("proforma_investisseur", { id, taux_hypo_pct: input.taux_hypo_pct, mise_de_fonds_pct: input.mise_de_fonds_pct, amortissement_ans: input.amortissement_ans, tga_pct: input.tga_pct, }) as Record; const proformaCondense = { hypotheses: pfFull.hypotheses, loyer_implicite_par_porte_mois: (pfFull.revenus as Record)?.loyer_implicite_par_porte_mois, rne_noi: (pfFull.exploitation as Record)?.rne_noi, tga_implicite_pct: (pfFull.exploitation as Record)?.tga_implicite_pct, cashflow_par_porte_mois: (pfFull.financement as Record)?.cashflow_par_porte_mois, dscr: (pfFull.financement as Record)?.dscr, liquidites_requises_totales: (pfFull.acquisition_tout_le_kit as Record)?.liquidites_requises_totales, cash_on_cash_pct: (pfFull.acquisition_tout_le_kit as Record)?.cash_on_cash_pct, projection_5_ans: pfFull.projection_5_ans, marges_de_securite: { loyer_point_mort_par_porte: (pfFull.marges_de_securite as Record)?.loyer_point_mort_par_porte, taux_point_mort_pct: (pfFull.marges_de_securite as Record)?.taux_point_mort_pct, }, }; // benchmark $/porte du gabarit dans la municipalité const bandCond = doors <= 3 ? "nb_logements = ?" : doors <= 5 ? "nb_logements BETWEEN 4 AND 5" : doors <= 12 ? "nb_logements BETWEEN 6 AND 12" : "nb_logements >= 13"; const bandArgs: unknown[] = doors <= 3 ? [u.municipalite, doors] : [u.municipalite]; const bench = u.municipalite ? (getDb() .prepare( `SELECT COUNT(*) n, AVG(est_2026 / nb_logements) prix_porte_moyen FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL AND ${bandCond}` ) .get(...bandArgs) as { n: number; prix_porte_moyen: number | null }) : null; const idx = getMarketIndex("plex"); const comps = res.comps.slice(0, 5).map((c) => ({ adresse: [c.street, c.city].filter(Boolean).join(", "), vendu_le: c.date, prix_paye: c.amount, portes: c.doors ?? null, prix_ajuste: r(c.adjustedPrice), poids_pct: r(c.weight * 100, 1), })); const h21 = u.history.find((h) => h.year === 2021)?.value; const h26 = u.history.find((h) => h.year === 2026)?.value; const valeurParPorte = r(res.estimate / doors); const benchPorte = bench?.prix_porte_moyen ? r(bench.prix_porte_moyen) : null; return { evaluation: evalCard(ev), proforma: proformaCondense, marche_local_du_gabarit: bench ? { municipalite: u.municipalite, plex_comparables_dans_la_ville: bench.n, valeur_moyenne_par_porte: benchPorte, } : null, tendance_marche_plex: { croissance_12_mois_pct: growth(idx, 12), croissance_24_mois_pct: growth(idx, 24), }, top_comparables: comps, synthese: { valeur_par_porte: valeurParPorte, ecart_par_porte_vs_ville_pct: valeurParPorte && benchPorte ? r((valeurParPorte / benchPorte - 1) * 100, 1) : null, croissance_plex_2021_2026_pct: h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null, note: "Le pro forma part du loyer implicite (supposé par la valeur au TGA de référence), pas des baux réels.", }, }; } case "liens_rapports": { const id = resolveId(input.id); const u = getUnit(id); if (!u) return { erreur: ID_ERR }; return { ...liens(id), note: "Le rapport standard inclut le pro forma ; le professionnel (6 pages) est le format bancaire.", }; } default: return { erreur: `Outil inconnu : ${name}` }; } } export { DEFAULT_PARAMS, tgaReference };