// UQO Éval — laboratoire pédagogique d'évaluation immobilière /** * Éva — la panoplie d'outils de l'assistante pédagogique d'UQO Éval. * Chaque outil est exécuté côté serveur, directement sur la base SQLite * (3,75 M unités) et le moteur d'estimation. Les sorties sont des JSON * compacts : Ka lit vite, coûte peu, et cite des chiffres exacts. */ import Anthropic from "@anthropic-ai/sdk"; import { getDb, getMarketIndex, getUnit, searchUnits } from "../db"; import { estimateByUnitId, estimateManual, estimatePortfolio, indexType, type UnitEstimate, } from "../estimator"; import stats from "../../data/stats.json"; import { prefillFromProperty, runEstimate, getEstimate, sanitizeInput, defaultInput } from "../cost/estimate"; import { buildContext, searchItems, getItem, priceHistory, allLabourRates, labourHistory, loadLocations, today as costToday } from "../cost/catalog"; import { assemblyUnitCost } from "../cost/engine"; import { deriveQuantities } from "../cost/geometry"; import { getCostDb } from "../cost/db"; import { CONDITIONS, TRADES, categoryLabel, tradeLabel } from "../cost/taxonomy"; import type { CostEstimate, CostInput, PriceKind, Quality } from "../cost/types"; /* ---------------------------------------------------------------- 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 = "Unité introuvable — l'id est peut-être mal recopié. Relance chercher_propriete et copie le champ `id` EXACTEMENT tel quel."; function liens(id: string) { const e = encodeURIComponent(id); return { fiche_complete: `https://www.uqo-eval.app/estimation/${e}`, rapport_standard_pdf: `https://www.uqo-eval.app/api/report?id=${e}`, rapport_professionnel_pdf: `https://www.uqo-eval.app/api/report/pro?id=${e}`, }; } 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, type: u.type_prop, usage: u.cubf_libelle, annee_construction: u.annee_construction, aire_habitable_m2: r(u.aire_etages_m2, 1), terrain_m2: r(u.superficie_terrain_m2), logements: u.nb_logements, valeur_role_2026: u.valeur_role, }; } function evalCard(e: UnitEstimate) { const u = e.unit; const res = e.result; return { unite: u ? { id: u.id, adresse: u.adresse, municipalite: u.municipalite, type: u.typeProp, usage: u.cubfLibelle, annee_construction: u.anneeConstruction, aire_habitable_m2: r(u.aireEtagesM2, 1), terrain_m2: r(u.superficieTerrainM2), logements: u.nbLogements, etages: u.specs.nbEtages, genre_construction: u.specs.genreConstruction, lien_physique: u.specs.lienPhysique, valeur_role_2026: u.valeurRole, valeur_terrain_role: u.specs.valeurTerrain, valeur_batiment_role: u.specs.valeurBatiment, valeur_role_anterieure: u.specs.valeurAnterieure, 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, dispersion_comparables_pct: r(res.compsDispersionPct, 1), valeur_par_m2: u?.aireEtagesM2 ? r(res.estimate / u.aireEtagesM2) : null, ecart_vs_role_pct: u?.valeurRole ? r((res.estimate / u.valeurRole - 1) * 100, 1) : null, }, historique_2021_2026: u?.history ?? null, }; } /* ------------------------------------------------- méthode du coût — aides */ function natureLabel(k: PriceKind): string { switch (k) { case "observed": return "● observé"; case "official": return "● officiel"; case "indexed": return "○ indexé (StatCan)"; case "derived": return "◐ calculé"; case "reference": return "◇ référence interne (hypothèse)"; default: return "◇ hypothèse"; } } function coutCard(est: CostEstimate) { const b = est.input.building; const d = est.depreciation; const c = est.confidence; const e = encodeURIComponent(est.id); return { estimation_id: est.id, date_prix: est.priceDate, batiment: { type: b.type, qualite: b.quality, aire_pi2: b.grossFloorAreaSqft, etages: b.stories, annee: b.yearBuilt, sous_sol: b.basement, garage: b.garage.type, revetement: b.siding, toiture: b.roof, chauffage: b.heating, salles_de_bain: b.bathrooms, logements: b.units }, localisation: { code: est.location.code, region: est.location.nameFr, facteur_materiaux: est.location.materialFactor, facteur_main_doeuvre: est.location.labourFactor, facteur_equipement: est.location.equipmentFactor }, cout_remplacement_neuf: est.replacementCostNew, fourchette_rcn: { p10: est.range.p10, p90: est.range.p90 }, rcn_par_pi2: est.perSqft, rcn_par_m2: est.perM2, decomposition: { couts_directs: est.directCost, materiaux: est.directMaterial, main_doeuvre: est.directLabour, equipement: est.directEquipment, couts_indirects: est.indirectCost, frais_generaux: est.contractorOverhead, profit: est.contractorProfit, contingence: est.contingency }, hypotheses: { indirects_pct_total: r(est.indirect.reduce((s, i) => s + i.pct, 0), 1), frais_generaux_pct: est.input.params.overheadPct, profit_pct: est.input.params.profitPct, contingence_pct: est.input.params.contingencyPct, vie_economique: d.economicLife, methode_depreciation: d.method }, categories: est.categories.slice().sort((a, b2) => b2.adjusted - a.adjusted).slice(0, 6).map((k) => ({ categorie: k.labelFr, materiaux: k.material, main_doeuvre: k.labour, equipement: k.equipment, total: k.adjusted, part_pct: k.sharePct })), assemblages_principaux: est.lines.slice().sort((a, b2) => b2.adjusted - a.adjusted).slice(0, 6).map((l) => ({ code: l.assemblyCode, nom: l.nameFr, quantite: l.quantity, unite: l.unit, cout: l.adjusted })), depreciation: { methode: d.method, age_chronologique: d.chronologicalAge, age_effectif: d.effectiveAge, vie_economique: d.economicLife, physique_pct: d.physicalPct, physique: d.physical, fonctionnelle: d.functional, externe: d.external, total: d.total }, valeur_depreciee_batiment: d.depreciatedImprovementValue, terrain: { valeur: est.landValue, source: est.input.land.source, methode: est.input.land.method || null }, indication_par_le_cout: est.costApproachValue, confiance: { total: c.total, niveau: c.letter, fraicheur_20: c.freshness, couverture_20: c.coverage, localisation_15: c.location, main_doeuvre_15: c.labour, benchmarks_10: c.benchmarks, batiment_20: c.building, notes: c.notesFr }, part_materiaux_prix_observes_pct: r(est.coverage.materialObservedShare * 100), benchmarks: est.benchmarks.length ? est.benchmarks.map((k) => ({ source: k.source, marche: k.market, plage: `${k.low}-${k.high} ${k.unit}`, uqo_eval: k.estimatePerUnit, etat: k.status })) : "donnée non disponible (aucun benchmark importé)", autres_lectures: est.otherReadings, avertissements: est.warnings, liens: { page_cout: `https://www.uqo-eval.app/cout?estimate=${e}`, rapport_pdf: `https://www.uqo-eval.app/api/cost/report?estimate=${e}` }, }; } /* ------------------------------------------------------------ tool defs */ export const KA_TOOLS: Anthropic.Tool[] = [ { name: "chercher_propriete", description: "Recherche plein-texte d'une propriété parmi les 3 747 008 unités d'évaluation 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 (« voici ce qui s'en rapproche ») 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 d'adresse, ex. « 861 route Elgin Saint-Pamphile »", }, limite: { type: "number", description: "Nombre max de résultats (défaut 6, max 12)" }, }, required: ["requete"], }, }, { name: "evaluer_propriete", description: "Évaluation complète d'une propriété par son id (obtenu via chercher_propriete) : valeur estimée, fourchette P10-P90, indice de confiance A-D, décomposition modèle hédonique vs comparables, valeur au rôle, valeur/m², historique 2021-2026. C'est l'outil central — appelle-le une fois l'unité confirmée.", input_schema: { type: "object", properties: { id: { type: "string", description: "Identifiant provincial de l'unité" } }, required: ["id"], }, }, { name: "comparables_detailles", description: "Liste détaillée des ventes comparables utilisées dans l'évaluation d'une unité : adresse, date, prix payé, distance, ajustements (marché, superficie, âge) en dollars, prix ajusté et poids. Utile quand l'utilisateur demande « pourquoi ce prix ? » ou veut voir les ventes du voisinage.", input_schema: { type: "object", properties: { id: { type: "string", description: "Identifiant provincial de l'unité" }, max: { type: "number", description: "Nombre max de comparables (défaut 8)" }, }, required: ["id"], }, }, { name: "indice_marche", description: "Tendance du marché québécois par type de propriété (unifamilial, condo, plex) : indice mensuel $/m² et croissance sur 12 et 24 mois. Utile pour contextualiser une évaluation ou répondre à « le marché monte-t-il ? ».", input_schema: { type: "object", properties: { type: { type: "string", enum: ["unifamilial", "condo", "plex"], description: "Type de marché", }, }, required: ["type"], }, }, { name: "stats_municipalite", description: "Statistiques en direct d'une municipalité : nombre d'unités, valeur totale et médiane estimée 2026, valeur au rôle, répartition par type. Calculé en direct sur la base. Utile pour comparer une propriété à son marché local.", input_schema: { type: "object", properties: { municipalite: { type: "string", description: "Nom exact de la municipalité, ex. « Lévis »" } }, required: ["municipalite"], }, }, { name: "stats_provinciales", description: "Les grands chiffres du Québec : valeur totale de tout l'immobilier de la province (2,01 billions $), unités, logements, croissance 2021→2026, top municipalités, répartition par type. Pour les questions macro.", input_schema: { type: "object", properties: {} }, }, { name: "evaluer_parc", description: "Évalue un parc immobilier complet (2 à 40 unités par leurs ids) : valeur totale agrégée, fourchette, confiance pondérée, répartition par ville et par type, croissance du parc 2021→2026. Pour les investisseurs multi-propriétés.", input_schema: { type: "object", properties: { ids: { type: "array", items: { type: "string" }, description: "Identifiants provinciaux des unités du parc", }, }, required: ["ids"], }, }, { name: "comparer_proprietes", description: "Compare 2 à 4 propriétés côte à côte : valeur estimée, $/m², écart vs rôle, confiance, croissance 2021→2026. Pour aider un choix d'achat.", input_schema: { type: "object", properties: { ids: { type: "array", items: { type: "string" }, description: "2 à 4 identifiants" }, }, required: ["ids"], }, }, { name: "estimation_manuelle", description: "Estimation sans adresse exacte, à partir de caractéristiques : municipalité + type, et si possible superficie habitable, année, terrain. À utiliser seulement si chercher_propriete ne trouve rien ou pour un scénario hypothétique.", input_schema: { type: "object", properties: { municipalite: { type: "string" }, type: { type: "string", enum: ["unifamilial", "plex", "condo_ou_multi", "chalet", "maison_mobile", "terrain"], }, aire_habitable_m2: { type: "number" }, annee_construction: { type: "number" }, terrain_m2: { type: "number" }, }, required: ["municipalite", "type"], }, }, { name: "chercher_proprietes_secteur", description: "Trouve des propriétés dans une municipalité selon des critères : type, budget max (valeur estimée 2026), superficie min. Retourne les meilleures correspondances triées par valeur. Pour « trouve-moi une maison à Lévis sous 500 k$ ».", input_schema: { type: "object", properties: { municipalite: { type: "string" }, type: { type: "string", enum: ["unifamilial", "plex", "condo_ou_multi", "chalet", "maison_mobile", "terrain"], }, valeur_max: { type: "number", description: "Budget maximal ($)" }, valeur_min: { type: "number", description: "Valeur minimale ($)" }, aire_min_m2: { type: "number" }, limite: { type: "number", description: "Max résultats (défaut 8, max 15)" }, }, required: ["municipalite"], }, }, { name: "dossier_complet", description: "L'ARME LOURDE : dossier d'expert complet d'une propriété en UN SEUL appel — évaluation complète + statistiques du marché local + tendance du marché provincial + top 5 des comparables + synthèse chiffrée pré-calculée (position vs médiane municipale, $/m² vs comparables, croissance propriété vs marché). Utilise-le SYSTÉMATIQUEMENT quand l'utilisateur demande d'évaluer une propriété identifiée : tu obtiens tout pour livrer un mini-rapport d'expert d'un coup.", input_schema: { type: "object", properties: { id: { type: "string", description: "Identifiant provincial de l'unité" } }, required: ["id"], }, }, { name: "liens_rapports", description: "Donne les liens de téléchargement des rapports PDF d'une unité (rapport standard 3 pages et rapport professionnel bancaire 6 pages) et le lien de sa fiche complète. Offre-les à la fin d'une évaluation réussie.", input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], }, }, /* ------------------------------------------------ méthode du coût (IMM1033) */ { name: "cout_estimer_propriete", description: "MÉTHODE DU COÛT (IMM1033) pour une propriété du rôle, par son id (chercher_propriete d'abord) : coût de remplacement à neuf (RCN) décomposé par catégorie (matériaux / main-d'œuvre / équipement), coûts indirects, frais généraux, profit, contingence, fourchette P10-P90, $/pi², dépréciation (physique âge-vie ou par composante, fonctionnelle, externe), valeur du terrain au rôle, INDICATION DE VALEUR PAR LE COÛT, indice de confiance A-D décomposé, part des matériaux à prix observés vs prix de référence (hypothèses), autres lectures (hédonique, comparables, hybride, rôle) et liens (page /cout + PDF). Les caractéristiques absentes du rôle (revêtement, toiture, chauffage, sous-sol, qualité…) sont des HYPOTHÈSES par défaut — dis-le. Utilise-le pour « combien coûterait cette maison à reconstruire ? ».", input_schema: { type: "object", properties: { id: { type: "string", description: "Identifiant provincial de l'unité" }, qualite: { type: "string", enum: ["economy", "standard", "superior", "prestige"], description: "Qualité de construction (défaut standard)" }, sous_sol: { type: "string", enum: ["none", "crawl", "unfinished", "partial", "finished", "walkout"] }, garage: { type: "string", enum: ["none", "attached", "detached", "integrated"] }, revetement: { type: "string", enum: ["vinyl", "brick", "fiber_cement", "wood", "stone", "steel"], description: "Revêtement principal" }, toiture: { type: "string", enum: ["asphalt_shingle", "metal", "membrane"] }, chauffage: { type: "string", enum: ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "hydronic"] }, salles_de_bain: { type: "number" }, methode_depreciation: { type: "string", enum: ["age_life", "components"] }, age_effectif: { type: "number", description: "Âge effectif en années (défaut : âge chronologique)" }, }, required: ["id"], }, }, { name: "cout_estimer_construction", description: "MÉTHODE DU COÛT pour un bâtiment HYPOTHÉTIQUE (sans adresse) : municipalité + type + aire en pi² (défauts : maison détachée standard 1 800 pi², 2 étages, sous-sol non fini, vinyle, bardeau, plinthes électriques). Retourne RCN décomposé, fourchette, $/pi², hypothèses, confiance, liens. Pas de terrain sauf si fourni. Pour « combien coûte construire une maison de 2 000 pi² à Sherbrooke ? ».", input_schema: { type: "object", properties: { municipalite: { type: "string" }, type: { type: "string", enum: ["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile"] }, qualite: { type: "string", enum: ["economy", "standard", "superior", "prestige"] }, aire_pi2: { type: "number", description: "Aire d'étages hors sous-sol (pi²)" }, etages: { type: "number" }, annee_construction: { type: "number" }, sous_sol: { type: "string", enum: ["none", "crawl", "unfinished", "partial", "finished", "walkout"] }, garage: { type: "string", enum: ["none", "attached", "detached", "integrated"] }, places_garage: { type: "number" }, revetement: { type: "string", enum: ["vinyl", "brick", "fiber_cement", "wood", "stone", "steel"] }, toiture: { type: "string", enum: ["asphalt_shingle", "metal", "membrane"] }, chauffage: { type: "string", enum: ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "hydronic"] }, salles_de_bain: { type: "number" }, salles_d_eau: { type: "number" }, logements: { type: "number" }, valeur_terrain: { type: "number", description: "Valeur du terrain à ajouter ($), si connue" }, }, required: [], }, }, { name: "cout_detail_assemblage", description: "Détail d'un assemblage de construction (ex. STR-WALL-2X6-EXT, ROOF-ASPHALT-ARCH, KIT-STANDARD, BATH-STANDARD, FND-WALL-POURED-8IN) : composition (articles, quantités par unité, pertes), prix unitaire de chaque article avec sa nature (observé / indexé / référence-hypothèse), source et date, heures de main-d'œuvre par métier et taux employeur, coût d'équipement, coût direct par unité (matériaux + main-d'œuvre + équipement), vie économique. Pour « pourquoi la toiture coûte-t-elle X ? » (prendre le code dans le résultat de cout_estimer_*).", input_schema: { type: "object", properties: { code: { type: "string", description: "Code d'assemblage" }, localisation: { type: "string", description: "Code de localisation (défaut QC-MTL), ex. QC-GAT, QC-QUE" }, }, required: ["code"], }, }, { name: "cout_chercher_articles", description: "Recherche d'articles (coûts unitaires de matériaux) dans la base de coûts : ex. « gypse », « 2x6 », « bardeau », « fenêtre ». Retourne prix canonique courant, unité, fourchette min-max des sources, nombre de sources, date de la dernière observation et NATURE du prix (● observé chez un détaillant, ○ indexé StatCan, ◇ prix de référence interne = hypothèse). Sert aussi à trouver un code d'article pour cout_historique_materiau.", input_schema: { type: "object", properties: { requete: { type: "string" }, limite: { type: "number", description: "Max résultats (défaut 8, max 20)" } }, required: ["requete"], }, }, { name: "cout_taux_main_doeuvre", description: "Taux de main-d'œuvre de la construction résidentielle au Québec (grilles APCHQ fondées sur les conventions collectives CCQ) : coût horaire EMPLOYEUR complet par métier (salaire de base + indemnité de congés 13 % + avantages sociaux + cotisations AE/RQAP/FSS/CCQ/CNESST…), secteur, date d'entrée en vigueur, source et historique. Sans métier : tous les métiers. Rappelle que le taux employeur n'est pas le salaire horaire.", input_schema: { type: "object", properties: { metier: { type: "string", description: "Code de métier (charpentier, electricien, plombier, briqueteur, couvreur, peintre, carreleur…) — optionnel" } }, required: [], }, }, { name: "cout_historique_materiau", description: "Série temporelle des prix d'un article (code obtenu via cout_chercher_articles) : chaque observation datée avec sa source (détaillant), son unité d'origine, le facteur de conversion vers l'unité canonique, prix régulier ou promotion, aberrations marquées. Pour « le prix du bois a-t-il monté ? ».", input_schema: { type: "object", properties: { code: { type: "string" } }, required: ["code"], }, }, { name: "cout_expliquer_depreciation", description: "Explique la dépréciation d'une estimation par la méthode du coût (id d'estimation renvoyé par cout_estimer_*) : âge chronologique vs âge effectif vs vie économique, méthode âge-vie ou par composante (tableau : RCN, vie, condition, âge effectif, % et $ par composante), désuétude fonctionnelle (curable / incurable) et externe, règles condition → âge effectif, et définitions pédagogiques (détérioration physique, désuétude fonctionnelle, désuétude externe, coût de reproduction vs coût de remplacement).", input_schema: { type: "object", properties: { estimation_id: { type: "string", description: "Identifiant (uuid) de l'estimation" } }, required: ["estimation_id"], }, }, { name: "cout_comparer_localisations", description: "Compare les régions du Québec pour la méthode du coût : facteurs distincts matériaux / main-d'œuvre / équipement (la main-d'œuvre est uniforme au Québec — conventions CCQ — sauf régions éloignées) et coût direct d'une maison type de 1 800 pi² par région. Sans codes : toutes les régions.", input_schema: { type: "object", properties: { codes: { type: "array", items: { type: "string" }, description: "Codes de localisation (QC-MTL, QC-QUE, QC-GAT, QC-SHE, QC-SAG, QC-ABT, QC-CDN, QC-GIM, QC-NDQ…)" } }, required: [], }, }, { name: "cout_sources", description: "Registre des sources de la base de coûts : nom, type (main-d'œuvre, indice, détaillant, benchmark, référence), statut de licence, active ou non, dernière synchronisation, nombre d'observations. Pour « d'où viennent les prix ? ».", input_schema: { type: "object", properties: {} }, }, ]; /* ------------------------------------------------------------ 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_propriete": { 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 résultat, même approximatif. Essayer une graphie différente (« numéro rue municipalité »), ou proposer estimation_manuelle.", }; return { correspondance: niveau, ...(niveau === "approximative" ? { note: "Correspondance exacte introuvable — voici les adresses les plus proches (même voie ou même secteur). Présente-les à l'utilisateur comme des suggestions.", } : {}), resultats: rows.map(unitCard), }; } case "evaluer_propriete": { const e = estimateByUnitId(resolveId(input.id)); if (!e) return { erreur: ID_ERR }; return evalCard(e); } case "comparables_detailles": { const e = estimateByUnitId(resolveId(input.id)); if (!e) return { erreur: ID_ERR }; const max = Math.min(Number(input.max) || 8, 15); 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, distance_m: r(c.distanceM), il_y_a_mois: c.monthsAgo, ajust_marche: r(c.adjTime), ajust_superficie: r(c.adjArea), ajust_age: r(c.adjAge), prix_ajuste: r(c.adjustedPrice), poids_pct: r(c.weight * 100, 1), })), }; } case "indice_marche": { const idx = getMarketIndex(String(input.type ?? "unifamilial")); if (!idx.length) return { erreur: "Type d'indice inconnu." }; const last12 = idx.slice(-13); return { type: input.type, 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² lissé 3 mois, 1.0 = niveau actuel du marché.", }; } case "stats_municipalite": { const mun = String(input.municipalite ?? ""); const g = getDb() .prepare( `SELECT COUNT(*) n, SUM(est_2026) total, SUM(valeur_role) role_total FROM units WHERE municipalite = ? COLLATE NOCASE` ) .get(mun) as { n: number; total: number | null; role_total: number | null }; if (!g?.n) return { erreur: `Municipalité « ${mun} » introuvable (nom exact requis).` }; const med = getDb() .prepare( `SELECT est_2026 v 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 } | undefined; const types = getDb() .prepare( `SELECT type_prop, COUNT(*) n, SUM(est_2026) total FROM units WHERE municipalite = ? COLLATE NOCASE GROUP BY type_prop ORDER BY total DESC` ) .all(mun) as { type_prop: string; n: number; total: number | null }[]; return { municipalite: mun, unites: g.n, valeur_totale_estimee_2026: r(g.total), valeur_role_totale: r(g.role_total), valeur_mediane_2026: med?.v ?? null, par_type: types.map((t) => ({ type: t.type_prop, unites: t.n, total: r(t.total) })), }; } 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_immobilier_quebec_2026: s.valeur_totale_2026, valeur_role_totale: s.valeur_role_totale, unites_evaluees: s.unites, logements: s.logements, valeur_mediane_2026: s.valeur_mediane_2026, croissance_2021_2026_pct: s.croissance_2021_2026_pct, municipalites: s.municipalites, par_type: 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: "Aucune unité valide trouvée." }; const a = p.aggregates; return { unites_evaluees: 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 }, logements_totaux: a.totalDwellings, aire_totale_m2: a.totalFloorArea, croissance_parc_2021_2026_pct: r(a.growthPct, 1), par_ville: a.municipalities, par_type: a.types, detail: p.items.map((i) => ({ id: i.unit!.id, adresse: i.unit!.adresse, valeur: r(i.result.estimate), confiance: i.result.confidenceLevel, })), }; } case "comparer_proprietes": { 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 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, valeur: r(e.result.estimate), valeur_par_m2: e.unit.aireEtagesM2 ? r(e.result.estimate / e.unit.aireEtagesM2) : 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: String(input.type ?? "unifamilial"), 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_proprietes_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.type) { conds.push("type_prop = ?"); args.push(String(input.type)); } 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)); } if (input.aire_min_m2) { conds.push("aire_etages_m2 >= ?"); args.push(Number(input.aire_min_m2)); } 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: "Aucune propriété ne correspond aux critères." }; return { resultats: rows.map((u) => ({ ...unitCard(u), valeur_estimee_2026: r(u.est_2026) })), }; } case "dossier_complet": { const id = resolveId(input.id); const ev = estimateByUnitId(id); if (!ev || !ev.unit) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." }; const u = ev.unit; const res = ev.result; const marcheLocal = u.municipalite ? (runKaTool("stats_municipalite", { municipalite: u.municipalite }) as Record) : null; const idx = getMarketIndex(indexType(u.typeProp)); 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, prix_ajuste: r(c.adjustedPrice), distance_m: r(c.distanceM), poids_pct: r(c.weight * 100, 1), })); // synthèse pré-calculée — les chiffres qui font un avis d'expert const medianeMun = (marcheLocal?.valeur_mediane_2026 as number | null) ?? null; const h21 = u.history.find((h) => h.year === 2021)?.value; const h26 = u.history.find((h) => h.year === 2026)?.value; const croissanceProp = h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null; const compsM2 = res.comps .filter((c) => c.floorArea && c.floorArea > 20) .map((c) => c.adjustedPrice / c.floorArea!); const benchM2 = compsM2.length ? r(compsM2.sort((a, b) => a - b)[Math.floor(compsM2.length / 2)]) : null; const propM2 = u.aireEtagesM2 ? r(res.estimate / u.aireEtagesM2) : null; return { evaluation: evalCard(ev), marche_local: marcheLocal, tendance_marche_provincial: { type: indexType(u.typeProp), croissance_12_mois_pct: growth(idx, 12), croissance_24_mois_pct: growth(idx, 24), }, top_comparables: comps, synthese: { position_vs_mediane_municipale_pct: medianeMun && medianeMun > 0 ? r((res.estimate / medianeMun - 1) * 100, 1) : null, croissance_propriete_2021_2026_pct: croissanceProp, valeur_m2_propriete: propM2, valeur_m2_mediane_comparables: benchM2, ecart_m2_vs_comparables_pct: propM2 && benchM2 ? r((propM2 / benchM2 - 1) * 100, 1) : null, }, }; } case "liens_rapports": { const id = resolveId(input.id); const u = getUnit(id); if (!u) return { erreur: ID_ERR }; return { ...liens(id), note: "Le rapport professionnel (6 pages) est le format à présenter à une banque.", }; } /* ------------------------------------------------ méthode du coût (IMM1033) */ case "cout_estimer_propriete": { const id = resolveId(input.id); const p = prefillFromProperty(id); if (!p) return { erreur: ID_ERR }; const b = p.input.building; const src = p.input.attributeSources; if (input.qualite) { b.quality = String(input.qualite) as Quality; b.kitchenQuality = b.quality; b.bathroomQuality = b.quality; src.quality = "user"; src.kitchenQuality = "user"; } if (input.sous_sol) { b.basement = String(input.sous_sol) as CostInput["building"]["basement"]; b.basementFinishedPct = b.basement === "finished" || b.basement === "walkout" ? 1 : b.basement === "partial" ? 0.5 : 0; src.basement = "user"; } if (input.garage) { b.garage = { type: String(input.garage) as CostInput["building"]["garage"]["type"], spaces: input.garage === "none" ? 0 : 1, areaSqft: null }; src.garage = "user"; } if (input.revetement) { b.siding = { [String(input.revetement)]: 1 }; src.siding = "user"; } if (input.toiture) { b.roof = String(input.toiture) as CostInput["building"]["roof"]; src.roof = "user"; } if (input.chauffage) { b.heating = String(input.chauffage) as CostInput["building"]["heating"]; src.heating = "user"; } if (input.salles_de_bain != null) { b.bathrooms = Number(input.salles_de_bain); src.bathrooms = "user"; } if (input.methode_depreciation) p.input.depreciation.method = String(input.methode_depreciation) as "age_life" | "components"; if (input.age_effectif != null) { p.input.depreciation.effectiveAge = Number(input.age_effectif); p.input.depreciation.effectiveAgeSource = "user"; } const est = runEstimate(sanitizeInput(p.input), { save: true }); return { ...coutCard(est), caracteristiques_supposees: p.missing.filter((m) => !["landValue"].includes(m)), note: "Les caractéristiques « supposées » sont des hypothèses par défaut (le rôle MAMH ne les publie pas) : l'étudiant peut les corriger dans la page /cout. Coût de REMPLACEMENT (composantes modernes équivalentes), pas de reproduction.", }; } case "cout_estimer_construction": { const inp = defaultInput(); const b = inp.building; const src = inp.attributeSources; inp.municipality = input.municipalite ? String(input.municipalite) : null; if (input.type) { b.type = String(input.type) as CostInput["building"]["type"]; src.type = "user"; } if (input.qualite) { b.quality = String(input.qualite) as Quality; b.kitchenQuality = b.quality; b.bathroomQuality = b.quality; src.quality = "user"; src.kitchenQuality = "user"; } if (input.aire_pi2) { b.grossFloorAreaSqft = Number(input.aire_pi2); src.grossFloorAreaSqft = "user"; } if (input.etages) { b.stories = Number(input.etages); src.stories = "user"; } if (input.annee_construction) { b.yearBuilt = Number(input.annee_construction); src.yearBuilt = "user"; } if (input.sous_sol) { b.basement = String(input.sous_sol) as CostInput["building"]["basement"]; b.basementFinishedPct = b.basement === "finished" || b.basement === "walkout" ? 1 : b.basement === "partial" ? 0.5 : 0; src.basement = "user"; } if (input.garage) { b.garage = { type: String(input.garage) as CostInput["building"]["garage"]["type"], spaces: input.garage === "none" ? 0 : Number(input.places_garage) || 1, areaSqft: null }; src.garage = "user"; } if (input.revetement) { b.siding = { [String(input.revetement)]: 1 }; src.siding = "user"; } if (input.toiture) { b.roof = String(input.toiture) as CostInput["building"]["roof"]; src.roof = "user"; } if (input.chauffage) { b.heating = String(input.chauffage) as CostInput["building"]["heating"]; src.heating = "user"; } if (input.salles_de_bain != null) { b.bathrooms = Number(input.salles_de_bain); src.bathrooms = "user"; } if (input.salles_d_eau != null) b.powderRooms = Number(input.salles_d_eau); if (input.logements) { b.units = Number(input.logements); b.kitchens = b.units; } if (input.valeur_terrain) inp.land = { value: Number(input.valeur_terrain), source: "user", rollYear: null, method: "valeur fournie par l'utilisateur" }; const est = runEstimate(sanitizeInput(inp), { save: true }); return { ...coutCard(est), note: "Bâtiment hypothétique : tout paramètre non fourni est une hypothèse par défaut. Sans valeur de terrain, l'indication par le coût = valeur dépréciée du bâtiment seulement." }; } case "cout_detail_assemblage": { const code = String(input.code ?? "").toUpperCase().trim(); const ctx = buildContext({ locationCode: input.localisation ? String(input.localisation) : "QC-MTL" }); const a = ctx.assemblies.get(code); if (!a) { const near = [...ctx.assemblies.keys()].filter((k) => k.includes(code.split("-")[0] ?? "")).slice(0, 12); return { erreur: `Assemblage « ${code} » introuvable.`, codes_proches: near }; } const u = assemblyUnitCost(a, ctx); const f = ctx.location; return { code: a.code, nom: a.nameFr, categorie: categoryLabel(a.category, true), unite: a.unit, description: a.descriptionFr, vie_economique_ans: a.economicLife, groupe_condition: a.conditionGroup, localisation: { code: f.code, nom: f.nameFr, facteur_materiaux: f.materialFactor, facteur_main_doeuvre: f.labourFactor, facteur_equipement: f.equipmentFactor }, cout_unitaire: { materiaux: u.material, main_doeuvre: u.labour, equipement: u.equipment, direct: u.direct, direct_localise: r(u.material * f.materialFactor + u.labour * f.labourFactor + u.equipment * f.equipmentFactor, 2), incertitude_pct: r(u.sigmaPct * 100, 1), part_materiaux_observes_pct: r(u.observedShare * 100) }, composants: u.components.map((c) => ({ article: c.itemCode || null, nom: c.nameFr, quantite_par_unite: c.itemCode ? r(c.quantity, 4) : undefined, pertes_pct: c.itemCode ? r(c.wasteFactor * 100) : undefined, unite: c.itemCode ? c.unit : undefined, prix_unitaire: c.itemCode ? r(c.unitPrice, 2) : undefined, cout_materiau: c.itemCode ? r(c.materialCost, 2) : undefined, nature_prix: c.itemCode ? natureLabel(c.provenance.kind) : undefined, source: c.itemCode ? c.provenance.source : undefined, date: c.itemCode ? c.provenance.observedAt ?? null : undefined, note: c.provenance.note ?? undefined, heures: c.labourHours || undefined, metier: c.trade ? tradeLabel(c.trade, true) : undefined, taux_employeur: c.hourlyRate ?? undefined, cout_main_doeuvre: c.labourCost || undefined, nature_taux: c.rateProvenance ? natureLabel(c.rateProvenance.kind) + " — " + c.rateProvenance.source : undefined, equipement: c.equipmentCost || undefined, })), legende: "● observé/officiel · ◐ calculé · ○ indexé (StatCan) · ◇ référence interne = hypothèse, pas une observation", }; } case "cout_chercher_articles": { const q = String(input.requete ?? ""); const limit = Math.min(Number(input.limite) || 8, 20); const items = searchItems(q, limit); if (!items.length) return { resultats: [], note: "Aucun article — essayer un mot plus court (gypse, isolant, bardeau, fenêtre, béton…)." }; const ctx = buildContext({ locationCode: "QC-MTL" }); return { date_prix: ctx.asOf, resultats: items.map((i) => { const p = ctx.prices.get(i.code); return { code: i.code, nom: i.nameFr, unite: i.unit, categorie: categoryLabel(i.category, true), prix: p ? r(p.price, 2) : null, min: p ? r(p.low, 2) : null, max: p ? r(p.high, 2) : null, nature_prix: p ? natureLabel(p.kind) : "donnée non disponible", nb_sources: p?.sourceCount ?? 0, derniere_observation: p?.observedAt ?? null, sources: p?.sources.map((s) => `${s.source} ${r(s.price, 2)} $ (${s.observedAt})`).slice(0, 4), note: p?.note ?? undefined }; }), legende: "● observé chez un détaillant · ○ indexé par StatCan 18-10-0289 · ◇ prix de référence interne = hypothèse documentée, non observée", }; } case "cout_taux_main_doeuvre": { const asOf = costToday(); if (input.metier) { const code = String(input.metier).toLowerCase(); const t = TRADES.find((x) => x.code === code || x.fr.toLowerCase().includes(code)); if (!t) return { erreur: `Métier inconnu — codes : ${TRADES.map((x) => x.code).join(", ")}` }; const hist = labourHistory(t.code); if (!hist.length) return { metier: t.fr, erreur: "Donnée non disponible : aucune grille synchronisée pour ce métier (le taux de repli 85 $/h — hypothèse — est alors utilisé par le moteur)." }; return { metier: t.fr, code: t.code, grilles: hist.map((h) => ({ secteur: h.sector, classification: h.classification, en_vigueur: h.effectiveFrom, salaire_base: h.baseWage, indemnite_vacances: h.vacationCost, avantages_sociaux: h.benefitsCost, cotisations_employeur: h.employerContributions, autres: h.otherContributions, cout_horaire_employeur_total: h.totalEmployerCost, source: h.source, url: h.sourceUrl })), note: "Le coût employeur complet = salaire conventionné CCQ + congés (13 %) + avantages sociaux + AE, RQAP, RRQ, FSS, CNESST, prélèvement CCQ, AECQ, fonds de formation. C'est ce coût — pas le salaire nu — que le moteur multiplie par les heures.", }; } const rates = allLabourRates(asOf).filter((x) => x.classification === "compagnon"); if (!rates.length) return { erreur: "Donnée non disponible : aucune grille de main-d'œuvre synchronisée (le moteur utilise un taux de repli de 85 $/h, hypothèse)." }; return { date: asOf, taux: rates.map((h) => ({ metier: h.tradeNameFr, code: h.tradeCode, secteur: h.sector, en_vigueur: h.effectiveFrom, salaire_base: h.baseWage, cout_horaire_employeur_total: h.totalEmployerCost, source: h.source })), note: "Taux uniformes pour tout le Québec (conventions collectives, Loi R-20) ; secteur résidentiel léger = maisons ≤ 6 logements.", }; } case "cout_historique_materiau": { const code = String(input.code ?? "").toUpperCase().trim(); const item = getItem(code); if (!item) return { erreur: `Article « ${code} » introuvable — utiliser cout_chercher_articles.` }; const hist = priceHistory(code); const obs = hist.filter((h) => h.kind !== "reference"); return { article: { code: item.code, nom: item.nameFr, unite: item.unit, prix_reference_interne_hypothese: item.referencePrice, note_reference: item.referenceNote }, nb_observations: obs.length, historique: obs.slice(-40).map((h) => ({ date: h.date, prix: r(h.price, 2), source: h.source, unite_source: h.sourceUnit, facteur_conversion: r(h.conversionFactor, 5), regulier: h.regular, nature: h.kind, aberrant: h.outlier || undefined, url: h.url })), variation_pct: obs.length >= 2 && obs[0].price > 0 ? r((obs[obs.length - 1].price / obs[0].price - 1) * 100, 1) : null, note: obs.length ? undefined : "Donnée non disponible : aucune observation détaillant pour cet article — seul le prix de référence interne (hypothèse) existe.", }; } case "cout_expliquer_depreciation": { const id = String(input.estimation_id ?? ""); const est = getEstimate(id); if (!est) return { erreur: "Estimation introuvable — relancer cout_estimer_propriete / cout_estimer_construction et reprendre l'id retourné." }; const d = est.depreciation; return { estimation_id: est.id, rcn: est.replacementCostNew, ages: { chronologique: d.chronologicalAge, effectif_retenu: d.effectiveAge, vie_economique: d.economicLife, source_age_effectif: est.input.depreciation.effectiveAgeSource }, methode: d.method === "components" ? "composante par composante" : "âge-vie simple (âge effectif ÷ vie économique, plafonné à 90 %)", physique: { pct: d.physicalPct, montant: d.physical }, composantes: d.components.map((c) => ({ composante: c.labelFr, rcn: c.rcn, vie: c.economicLife, condition: c.condition, age_effectif: c.effectiveAge, depreciation_pct: c.depreciationPct, depreciation: c.depreciation, restant: c.remaining, regle: c.rule })), fonctionnelle: { curable: d.functionalCurable, incurable: d.functionalIncurable, total: d.functional, deficiences: est.input.depreciation.functional.map((f) => ({ type: f.type, curable: f.curable, cout_correction: f.costToCure, perte_valeur: f.valueLoss })) }, externe: { montant: d.external, note: est.input.depreciation.externalNote || "aucune preuve saisie → 0 $" }, total: d.total, valeur_depreciee_batiment: d.depreciatedImprovementValue, terrain: est.landValue, indication_par_le_cout: est.costApproachValue, regles_condition_age_effectif: CONDITIONS.map((c) => `${c.fr} → ${c.effectiveAgeRatio} × vie`).join(" · "), definitions: { deterioration_physique: "Usure et vieillissement des composantes (toiture, fenêtres, finis…) ; mesurée par âge-vie (âge effectif ÷ vie économique) ou composante par composante.", desuetude_fonctionnelle: "Perte de valeur due à la conception ou aux caractéristiques du bâtiment lui-même (trop peu de salles de bain, configuration dépassée, suramélioration) ; curable si le coût de correction < gain de valeur, sinon incurable.", desuetude_externe: "Perte de valeur due à des facteurs hors de la propriété (nuisance, marché structurellement faible) ; jamais calculée automatiquement sans preuve.", cout_reproduction_vs_remplacement: "Reproduction = reconstruire à l'identique (mêmes matériaux et méthodes) ; remplacement = bâtiment d'utilité équivalente avec les matériaux et normes actuels. UQO Éval calcule le coût de REMPLACEMENT.", }, }; } case "cout_comparer_localisations": { const codes = (input.codes as string[] | undefined)?.map((c) => String(c).toUpperCase()); const locs = loadLocations().filter((l) => !codes?.length || codes.includes(l.code)); if (!locs.length) return { erreur: "Aucune localisation — codes : " + loadLocations().map((l) => l.code).join(", ") }; const inp = defaultInput(); const { lines } = deriveQuantities(inp.building); const ctxBase = buildContext({ locationCode: "QC-MTL" }); let baseDirect = 0; const perLine = lines.map((q) => { const a = ctxBase.assemblies.get(q.assemblyCode); if (!a) return null; const u = assemblyUnitCost(a, ctxBase); baseDirect += u.direct * q.quantity; return { q: q.quantity, u }; }).filter((x): x is { q: number; u: ReturnType } => !!x); return { maison_type: "détachée standard 1 800 pi², 2 étages, sous-sol non fini, vinyle, bardeau, plinthes électriques (prix au " + ctxBase.asOf + ")", regions: locs.map((l) => { const direct = perLine.reduce((s, x) => s + x.q * (x.u.material * l.materialFactor + x.u.labour * l.labourFactor + x.u.equipment * l.equipmentFactor), 0); return { code: l.code, region: l.nameFr, facteur_materiaux: l.materialFactor, facteur_main_doeuvre: l.labourFactor, facteur_equipement: l.equipmentFactor, facteur_global: l.overallFactor, confiance: l.confidence, cout_direct_maison_type: r(direct), ecart_vs_montreal_pct: baseDirect ? r((direct / baseDirect - 1) * 100, 1) : null }; }), note: "Main-d'œuvre uniforme au Québec (conventions collectives CCQ) sauf régions éloignées ; facteurs matériaux/équipement = surcoût de transport (hypothèse UQO Éval, modifiable).", }; } case "cout_sources": { const rows = getCostDb().prepare(`SELECT s.name, s.source_type type, s.license_status licence, s.is_active active, s.last_successful_sync sync, s.base_url url, (SELECT COUNT(*) FROM cost_item_prices p WHERE p.source_id=s.id) + (SELECT COUNT(*) FROM labour_rates l WHERE l.source_id=s.id) obs FROM cost_sources s ORDER BY s.priority, s.name`).all() as { name: string; type: string; licence: string; active: number; sync: string | null; url: string | null; obs: number }[]; const idx = getCostDb().prepare("SELECT COUNT(DISTINCT index_code) n, MAX(period) last FROM construction_cost_indices").get() as { n: number; last: string | null }; return { sources: rows.map((x) => ({ source: x.name, type: x.type, licence: x.licence, active: x.active === 1, derniere_synchro: x.sync ?? "jamais", observations: x.obs, url: x.url })), indices_statcan: { series: idx.n, derniere_periode: idx.last }, note: "Aucun prix n'est inventé : observé (détaillants), officiel (APCHQ/CCQ, StatCan) ou prix de référence interne explicitement étiqueté « hypothèse ». Firecrawl ne tourne qu'en arrière-plan ; l'interface lit la base locale.", }; } default: return { erreur: `Outil inconnu : ${name}` }; } } // réexport pratique pour la route export { indexType };