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 * Rapport d'évaluation détaillé — format professionnel (présentation bancaire).4 * Structure calquée sur un rapport d'évaluation : page titre, sommaire exécutif,5 * mandat et portée, définition de la valeur marchande, méthodologie, description6 * de la propriété, grille d'ajustement des comparables, analyse hédonique,7 * réconciliation, hypothèses et conditions limitatives, certification.8 * Ton sobre, encadrés honnêtes : AVM statistique — n'est PAS un rapport OEAQ.9 */10import PDFDocument from "pdfkit";11import path from "path";12import type { UnitEstimate } from "./estimator";13import { buildProforma } from "./proforma";1415const INK = "#141814";16const INK2 = "#4d5551";17const INK3 = "#8b928c";18const SURFACE2 = "#faf9f5";19const GREEN = "#b25f16";20const GREEN_DEEP = "#8a4710";21const LIME = "#ff9f45";2223const W = 612;24const H = 792;25const M = 56;26const CW = W - 2 * M;2728const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);29const fmt = (v: number | null | undefined) =>30 v == null31 ? "—"32 : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);33const signed = (v: number) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)))}`;34const CONF_TXT: Record<string, string> = {35 A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable — marché mince",36};3738type Doc = InstanceType<typeof PDFDocument>;3940interface Ctx {41 doc: Doc;42 page: number;43 dossier: string;44 generated: string;45}4647function header(c: Ctx) {48 const { doc } = c;49 doc.font("SG-Bold").fontSize(10).fillColor(INK).text("Valo", M, 26, { lineBreak: false });50 const w = doc.widthOfString("Valo");51 doc.rect(M + w + 2, 25, doc.widthOfString("Plex") + 6, 13).fill(INK);52 doc.fillColor(LIME).text("Plex", M + w + 5, 26, { lineBreak: false });53 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)54 .text("RAPPORT D'ÉVALUATION DÉTAILLÉ — FORMAT PROFESSIONNEL", M + 70, 29, { characterSpacing: 0.8, lineBreak: false });55 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)56 .text(`DOSSIER ${c.dossier}`, W - M - 160, 29, { width: 160, align: "right" });57 doc.rect(M, 44, CW, 0.8).fill(INK);58}5960function footer(c: Ctx, total: number) {61 const { doc } = c;62 doc.rect(M, H - 52, CW, 0.8).fill(INK);63 doc.font("JB-Reg").fontSize(6).fillColor(INK3).text(64 "ESTIMATION STATISTIQUE AUTOMATISÉE (AVM) — NE CONSTITUE PAS UN RAPPORT D'ÉVALUATION AU SENS DES NORMES DE L'OEAQ · WWW.VALOPLEX.COM · CONTACT@SPBOUCHER.AI",65 M, H - 44, { width: CW - 50, characterSpacing: 0.3, lineBreak: false });66 doc.font("JB-Bold").fontSize(7).fillColor(INK)67 .text(`${c.page} / ${total}`, W - M - 40, H - 45, { width: 40, align: "right" });68}6970function newPage(c: Ctx, total: number) {71 c.doc.addPage({ size: "LETTER", margin: 0 });72 c.page += 1;73 c.doc.rect(0, 0, W, H).fill("#ffffff");74 header(c);75 footer(c, total);76 return 62; // y de départ77}7879function sectionTitle(c: Ctx, num: string, title: string, y: number): number {80 const { doc } = c;81 doc.font("JB-Bold").fontSize(8).fillColor(GREEN)82 .text(num, M, y + 2, { lineBreak: false });83 doc.font("SG-Bold").fontSize(13).fillColor(INK)84 .text(title.toUpperCase(), M + 30, y, { lineBreak: false });85 doc.rect(M, y + 19, CW, 1.4).fill(INK);86 doc.rect(M, y + 21.5, 60, 2.2).fill(LIME);87 return y + 32;88}8990function para(c: Ctx, txt: string, y: number, opts?: { size?: number; color?: string; width?: number }): number {91 const { doc } = c;92 doc.font("Inter").fontSize(opts?.size ?? 8.6).fillColor(opts?.color ?? INK2)93 .text(txt, M, y, { width: opts?.width ?? CW, lineGap: 2.2, align: "justify" });94 return doc.y + 8;95}9697function kvTable(c: Ctx, rows: [string, string][], y: number, colW = CW): number {98 const { doc } = c;99 const rh = 17;100 rows.forEach(([k, v], i) => {101 const ry = y + i * rh;102 if (i % 2 === 0) doc.rect(M, ry, colW, rh).fill(SURFACE2);103 doc.font("JB-Bold").fontSize(6.6).fillColor(INK3)104 .text(k.toUpperCase(), M + 8, ry + 5, { characterSpacing: 0.5, lineBreak: false });105 doc.font("SG-Bold").fontSize(8.6).fillColor(INK)106 .text(v, M + colW * 0.42, ry + 4, { width: colW * 0.58 - 10, height: 11, ellipsis: true });107 });108 doc.rect(M, y, colW, rows.length * rh).lineWidth(0.9).stroke(INK);109 return y + rows.length * rh + 10;110}111112export async function buildProReport(113 data: UnitEstimate,114 marketIndex: { month: string; idx: number }[]115): Promise<Buffer> {116 const u = data.unit!;117 const s = u.specs;118 const r = data.result;119 const generated = new Date().toISOString().slice(0, 10);120 const dossier = `VP-${u.id.slice(0, 8)}-${generated.replaceAll("-", "")}`;121 const TOTAL = 6;122123 const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: `Rapport d'évaluation détaillé — ${u.adresse ?? ""}` } });124 doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));125 doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));126 doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));127 doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));128 doc.registerFont("Inter", F("Inter-Regular.ttf"));129130 const chunks: Buffer[] = [];131 doc.on("data", (ch: Buffer) => chunks.push(ch));132 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));133 const c: Ctx = { doc, page: 1, dossier, generated };134135 /* ------------------------------- PAGE TITRE ------------------------------- */136 doc.rect(0, 0, W, H).fill("#ffffff");137 doc.rect(0, 0, W, 6).fill(INK);138 doc.rect(0, 6, W, 2).fill(LIME);139 // logo sobre140 doc.font("SG-Bold").fontSize(26).fillColor(INK).text("Valo", M, 70, { lineBreak: false });141 const lw = doc.widthOfString("Valo");142 doc.rect(M + lw + 4, 68, doc.widthOfString("Plex") + 14, 32).fill(INK);143 doc.fillColor(LIME).text("Plex", M + lw + 11, 70, { lineBreak: false });144 doc.font("JB-Reg").fontSize(7.5).fillColor(INK3)145 .text("MOTEUR D'ÉVALUATION IMMOBILIÈRE STATISTIQUE · QUÉBEC", M, 108, { characterSpacing: 1.2 });146147 doc.rect(M, 168, CW, 1.4).fill(INK);148 doc.font("SG-Bold").fontSize(30).fillColor(INK)149 .text("RAPPORT D'ÉVALUATION\nDÉTAILLÉ", M, 188, { lineGap: 4 });150 doc.font("Inter").fontSize(10.5).fillColor(INK2)151 .text("Approche par comparaison directe et modélisation hédonique de masse", M, 262);152 doc.rect(M, 288, CW, 1.4).fill(INK);153154 // bloc propriété155 doc.font("JB-Bold").fontSize(7.5).fillColor(INK3).text("PROPRIÉTÉ ÉVALUÉE", M, 318, { characterSpacing: 1.2 });156 doc.font("SG-Bold").fontSize(17).fillColor(INK)157 .text(`${u.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}`, M, 332);158 doc.font("SG-Med").fontSize(12).fillColor(INK2)159 .text(`${u.municipalite ?? ""} (Québec) · Matricule ${s.matricule ?? "—"}`, M, doc.y + 2);160161 // encadré conclusion162 const by = 420;163 doc.roundedRect(M, by, CW, 128, 6).lineWidth(1.6).fillAndStroke(SURFACE2, INK);164 doc.font("JB-Bold").fontSize(7.5).fillColor(GREEN_DEEP)165 .text("CONCLUSION DE LA VALEUR MARCHANDE ESTIMÉE", M + 20, by + 16, { characterSpacing: 1.2 });166 doc.font("SG-Bold").fontSize(38).fillColor(INK).text(fmt(r.estimate), M + 20, by + 32);167 doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FOURCHETTE (P10-P90)", M + 20, by + 84, { lineBreak: false });168 doc.font("SG-Bold").fontSize(11).fillColor(INK).text(`${fmt(r.low)} → ${fmt(r.high)}`, M + 20, by + 96, { lineBreak: false });169 doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("DATE D'EFFET", M + 260, by + 84, { lineBreak: false });170 doc.font("SG-Bold").fontSize(11).fillColor(INK).text(generated, M + 260, by + 96, { lineBreak: false });171 doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FIABILITÉ", M + 380, by + 84, { lineBreak: false });172 doc.font("SG-Bold").fontSize(11).fillColor(INK)173 .text(`${r.confidenceLevel} · ${r.confidencePct} %`, M + 380, by + 96, { lineBreak: false });174175 // préparé pour / par176 doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("PRÉPARÉ POUR", M, 585, { characterSpacing: 1 });177 doc.rect(M, 610, 220, 0.9).fill(INK2);178 doc.font("Inter").fontSize(7).fillColor(INK3).text("(destinataire / institution financière)", M, 615);179 doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("PRÉPARÉ PAR", M + 300, 585, { characterSpacing: 1 });180 doc.font("SG-Bold").fontSize(10).fillColor(INK).text("ValoPlex — moteur d'évaluation statistique", M + 300, 597);181 doc.font("Inter").fontSize(8).fillColor(INK2).text("Simon-Pierre Boucher · contact@spboucher.ai", M + 300, 611);182 doc.font("Inter").fontSize(8).fillColor(INK2).text(`Dossier ${dossier} · Généré le ${generated}`, M + 300, 623);183184 // avis185 doc.roundedRect(M, 668, CW, 54, 5).lineWidth(1).stroke(INK);186 doc.font("JB-Bold").fontSize(6.5).fillColor(INK).text("AVIS IMPORTANT", M + 12, 678, { characterSpacing: 1 });187 doc.font("Inter").fontSize(7.4).fillColor(INK2).text(188 "Ce rapport est produit automatiquement par un modèle d'évaluation de masse (AVM). Il est structuré selon les usages de la " +189 "pratique de l'évaluation afin d'en faciliter la lecture, mais il ne constitue pas un rapport d'évaluation préparé par un " +190 "évaluateur agréé (OEAQ) et aucune inspection de la propriété n'a été effectuée. Voir les hypothèses et conditions limitatives, section 7.",191 M + 12, 689, { width: CW - 24, lineGap: 1.5 });192 footer(c, TOTAL);193194 /* ------------------------- PAGE 2 : SOMMAIRE EXÉCUTIF ------------------------- */195 let y = newPage(c, TOTAL);196 y = sectionTitle(c, "1.", "Sommaire exécutif", y);197 y = kvTable(c, [198 ["Adresse", `${u.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}, ${u.municipalite ?? ""}`],199 ["Identifiant provincial", u.id],200 ["Matricule", s.matricule ?? "—"],201 ["Usage prédominant (CUBF)", `${s.cubf ?? "—"} — ${s.cubfLibelle ?? "—"}`],202 ["Droit évalué (présumé)", "Pleine propriété, libre de toute charge"],203 ["Date d'effet de l'estimation", generated],204 ["Méthodes utilisées", "Comparaison directe plex (ajustée) et modèle hédonique spécialisé"],205 ["Valeur estimée", fmt(r.estimate)],206 ["Fourchette P10-P90", `${fmt(r.low)} → ${fmt(r.high)}`],207 ["Indice de fiabilité", `${r.confidenceLevel} (${r.confidencePct} %) — ${CONF_TXT[r.confidenceLevel]}`],208 ["Évaluation municipale (rôle 2026)", fmt(u.valeurRole)],209 ["Écart estimation / rôle", u.valeurRole ? `${((r.estimate / u.valeurRole - 1) * 100).toFixed(1)} %` : "—"],210 ], y);211 y = para(c,212 "La valeur conclue résulte de la réconciliation pondérée de deux approches indépendantes : un modèle hédonique de " +213 `masse entraîné sur environ 690 000 transactions québécoises (pondération ${(r.modelWeight * 100).toFixed(0)} %) et l'analyse de ` +214 `${r.nCompsUsed} ventes comparables récentes situées à proximité du sujet, individuellement ajustées pour les conditions de ` +215 `marché, la superficie habitable et l'âge (pondération ${((1 - r.modelWeight) * 100).toFixed(0)} %). La dispersion des comparables ` +216 `ajustés est de ±${r.compsDispersionPct ?? "—"} %. L'indice de fiabilité intègre le nombre de comparables, leur cohérence et la ` +217 "largeur de la fourchette d'incertitude du modèle.", y);218219 y = sectionTitle(c, "2.", "Mandat, portée et définition de la valeur", y + 4);220 y = para(c,221 "Mandat — Estimer la valeur marchande la plus probable de la propriété identifiée ci-dessus, à la date d'effet, aux fins " +222 "d'information et d'appui à une démarche de financement. Portée — Analyse automatisée des données publiques du rôle " +223 "d'évaluation foncière (MAMH) et des transactions publiées ; aucune inspection, aucune vérification de titres.", y);224 y = para(c,225 "Définition de la valeur marchande — Le prix le plus probable, à la date d'effet, moyennant paiement comptant ou " +226 "conditions équivalentes, pour lequel la propriété devrait se vendre sur un marché concurrentiel et ouvert, dans le cadre " +227 "d'une transaction sans lien de dépendance entre un acheteur et un vendeur prudents et informés, aucune des parties " +228 "n'étant contrainte d'agir et un délai de mise en marché raisonnable étant accordé.", y);229 y = para(c,230 "Approches écartées — L'approche du coût n'est pas appliquée (absence de données fiables sur le coût de remplacement " +231 "déprécié sans inspection). L'approche du revenu n'est pas appliquée (propriété à vocation principalement résidentielle ; " +232 "données de loyers non disponibles dans les sources publiques utilisées).", y);233234 /* --------------------- PAGE 3 : DESCRIPTION DE LA PROPRIÉTÉ --------------------- */235 y = newPage(c, TOTAL);236 y = sectionTitle(c, "3.", "Description de la propriété", y);237 doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.1 Bâtiment", M, y); y += 16;238 y = kvTable(c, [239 ["Année de construction", u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? " (estimée au rôle)" : " (réelle)"}` : "—"],240 ["Aire d'étages (habitable)", u.aireEtagesM2 ? `${u.aireEtagesM2} m² (${Math.round(u.aireEtagesM2 * 10.7639)} pi²)` : "—"],241 ["Nombre d'étages", s.nbEtages != null ? String(s.nbEtages) : "—"],242 ["Genre de construction", s.genreConstruction ?? "—"],243 ["Lien physique", s.lienPhysique ?? "—"],244 ["Nombre de logements", u.nbLogements != null ? String(u.nbLogements) : "—"],245 ["Locaux non résidentiels", s.nbLocauxNonResid != null ? String(s.nbLocauxNonResid) : "—"],246 ], y);247 doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.2 Terrain", M, y); y += 16;248 y = kvTable(c, [249 ["Superficie du terrain", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m² (${Math.round(u.superficieTerrainM2 * 10.7639)} pi²)` : "—"],250 ["Mesure frontale", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"],251 ["Unité de voisinage (rôle)", s.uniteVoisinage || "—"],252 ["Arrondissement", s.arrond || "—"],253 ], y);254 doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.3 Données du rôle d'évaluation", M, y); y += 16;255 y = kvTable(c, [256 ["Valeur du terrain (rôle 2026)", fmt(s.valeurTerrain)],257 ["Valeur du bâtiment (rôle 2026)", fmt(s.valeurBatiment)],258 ["Valeur totale (rôle 2026)", fmt(u.valeurRole)],259 ["Valeur au rôle antérieur", fmt(s.valeurAnterieure)],260 ["Conditions du marché considérées au", s.datCondMarche ?? "—"],261 ], y);262 y = para(c,263 "Les caractéristiques ci-dessus proviennent du rôle d'évaluation foncière en vigueur (source : MAMH, données ouvertes). " +264 "Elles n'ont pas fait l'objet d'une vérification sur place.", y);265266 /* --------------------- PAGE 4 : GRILLE DES COMPARABLES --------------------- */267 y = newPage(c, TOTAL);268 y = sectionTitle(c, "4.", "Approche par comparaison — grille d'ajustement", y);269 const comps = r.comps.slice(0, 4);270 const colW = (CW - 118) / comps.length;271 const rowsG: [string, (ci: number) => string][] = [272 ["Adresse", (i) => `${comps[i].street ?? ""}`],273 ["Municipalité", (i) => comps[i].city ?? "—"],274 ["Date de vente", (i) => comps[i].date],275 ["Prix de vente", (i) => fmt(comps[i].amount)],276 ["Distance du sujet", (i) => comps[i].distanceM < 1000 ? `${Math.round(comps[i].distanceM)} m` : `${(comps[i].distanceM / 1000).toFixed(1)} km`],277 ["Aire d'étages", (i) => comps[i].floorArea ? `${comps[i].floorArea} m²` : "—"],278 ["Année constr.", (i) => comps[i].yearBuilt ? String(comps[i].yearBuilt) : "—"],279 ["Ajust. conditions de marché", (i) => signed(comps[i].adjTime)],280 ["Ajust. superficie", (i) => signed(comps[i].adjArea)],281 ["Ajust. âge", (i) => signed(comps[i].adjAge)],282 ["PRIX AJUSTÉ", (i) => fmt(Math.round(comps[i].adjustedPrice))],283 ["Pondération", (i) => `${(comps[i].weight * 100).toFixed(0)} %`],284 ];285 const rh = 20;286 // en-têtes colonnes287 doc.rect(M, y, 118, rh).fill(INK);288 doc.font("JB-Bold").fontSize(6.4).fillColor("#ffffff").text("ÉLÉMENT", M + 6, y + 7, { lineBreak: false });289 comps.forEach((_, i) => {290 doc.rect(M + 118 + i * colW, y, colW, rh).fillAndStroke(i % 2 ? SURFACE2 : "#ffffff", INK);291 doc.font("JB-Bold").fontSize(6.6).fillColor(GREEN_DEEP)292 .text(`COMPARABLE ${i + 1}`, M + 118 + i * colW + 6, y + 7, { lineBreak: false });293 });294 y += rh;295 rowsG.forEach(([label, get]) => {296 const bold = label === "PRIX AJUSTÉ";297 doc.rect(M, y, 118, rh).fillAndStroke(bold ? LIME : SURFACE2, INK);298 doc.font("JB-Bold").fontSize(6).fillColor(INK)299 .text(label.toUpperCase(), M + 6, y + 7, { width: 108, height: 8, ellipsis: true, lineBreak: false });300 comps.forEach((_, ci) => {301 doc.rect(M + 118 + ci * colW, y, colW, rh).fillAndStroke(bold ? "#f6fbe4" : "#ffffff", INK);302 doc.font(bold ? "SG-Bold" : "Inter").fontSize(bold ? 8.4 : 7.4).fillColor(bold ? INK : INK2)303 .text(get(ci), M + 118 + ci * colW + 6, y + (bold ? 5.5 : 6.5), { width: colW - 12, height: 10, ellipsis: true, lineBreak: false });304 });305 y += rh;306 });307 y += 10;308 y = para(c,309 `${r.nCompsUsed} ventes comparables ont été retenues au total (les ${comps.length} plus pondérées sont détaillées ci-dessus ; la liste ` +310 "complète figure dans le rapport standard). Chaque prix de vente est ajusté : (i) aux conditions actuelles du marché au moyen " +311 "d'un indice mensuel du prix médian au mètre carré du segment, (ii) pour l'écart de superficie habitable à raison de 50 % du " +312 "prix unitaire au m² du comparable, plafonné à ±25 %, et (iii) pour l'écart d'âge à raison de 0,5 % par année, plafonné à ±10 %. " +313 `La médiane pondérée des prix ajustés s'établit à ${fmt(r.compsEstimate)}.`, y);314315 // mini-indice de marché316 if (marketIndex.length > 3) {317 doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("4.1 Indice des conditions de marché du segment", M, y);318 y += 16;319 const gw = CW, gh = 84;320 doc.roundedRect(M, y, gw, gh, 5).lineWidth(1).fillAndStroke("#ffffff", INK);321 const vals = marketIndex.map((p) => p.idx);322 const mn = Math.min(...vals), mx = Math.max(...vals), span = mx - mn || 1;323 const pts = marketIndex.map((p, i) => [324 M + 14 + (i / (marketIndex.length - 1)) * (gw - 28),325 y + gh - 18 - ((p.idx - mn) / span) * (gh - 36),326 ]);327 doc.moveTo(pts[0][0], pts[0][1]);328 pts.slice(1).forEach(([px, py]) => doc.lineTo(px, py));329 doc.lineWidth(1.8).stroke(GREEN);330 const last = pts[pts.length - 1];331 doc.circle(last[0], last[1], 3).lineWidth(1.2).fillAndStroke(LIME, INK);332 doc.font("JB-Reg").fontSize(6).fillColor(INK3)333 .text(marketIndex[0].month, M + 14, y + gh - 12, { lineBreak: false })334 .text(marketIndex[marketIndex.length - 1].month, M + gw - 60, y + gh - 12, { width: 46, align: "right" });335 doc.font("JB-Reg").fontSize(6).fillColor(INK3)336 .text(`BASE 1,00 = ${marketIndex[marketIndex.length - 1].month} · MÉDIANE $/M² LISSÉE 3 MOIS`, M + 14, y + 8, { characterSpacing: 0.5 });337 }338339 /* ------------------- PAGE 5 : HÉDONIQUE + RÉCONCILIATION ------------------- */340 y = newPage(c, TOTAL);341 y = sectionTitle(c, "5.", "Analyse hédonique de masse", y);342 y = para(c,343 "Un modèle de gradient boosting (LightGBM) en RATIO prix/valeur au rôle — insensible à l'échelle, du duplex à la tour — est entraîné exclusivement sur environ 91 000 ventes de plex québécois (2021-2026) appariées " +344 "au rôle d'évaluation foncière géoréférencé. Vingt-cinq variables décrivent chaque plex (portes, aire par porte, mixité, âge, " +345 "localisation fine, valeurs au rôle par porte, temps). Sur un échantillon réservé de 13 617 ventes jamais vues, l'erreur " +346 "médiane est de 14,0 % et 65 % des estimations tombent à ±20 % du prix réel (ratio médian 0,973 — " +347 "validation selon la norme IAAO). L'incertitude propre à l'immeuble est quantifiée par deux modèles de " +348 "régression quantile (10e et 90e centiles), puis CALIBRÉE par méthode conforme : couverture de 80 % vérifiée empiriquement.", y);349 y = kvTable(c, [350 ["Estimation du modèle hédonique", fmt(r.modelEstimate)],351 ["Intervalle P10-P90 du modèle", `${fmt(r.low)} → ${fmt(r.high)} (recentré)`],352 ["Erreur médiane du modèle (validation)", "14,0 % (MdAPE, holdout plex)"],353 ], y);354355 // pro forma indicatif (approche revenu inversée)356 const doorsN = u.nbLogements ?? 0;357 if (doorsN >= 2 && r.estimate > 0) {358 const pf = buildProforma(r.estimate, doorsN, u.valeurRole, u.municipalite);359 doc.font("SG-Bold").fontSize(9.5).fillColor(INK)360 .text("5.1 Pro forma indicatif (approche revenu inversée)", M, y);361 y += 16;362 y = kvTable(c, [363 ["Loyer moyen implicite / porte / mois", fmt(Math.round(pf.loyerMoyenMensuel))],364 [`Revenus bruts (${doorsN} portes)`, fmt(Math.round(pf.revenusBruts))],365 ["Revenu net d'exploitation (RNE)", fmt(Math.round(pf.rne))],366 ["TGA implicite / MRB", `${pf.tgaImplicitePct.toFixed(2)} % / × ${pf.mrb.toFixed(1)}`],367 [`Paiement hypothécaire (${pf.params.tauxHypoPct} %, ${pf.params.amortAns} ans, mise ${pf.params.miseDeFondsPct} %)`, `${fmt(Math.round(pf.paiementMensuelHypo))} / mois`],368 ["Cashflow avant impôt / DSCR", `${fmt(Math.round(pf.cashflowAnnuel))} / an · DSCR ${pf.dscr.toFixed(2)}`],369 ["Capital remboursé an 1 / rendement global an 1", `${fmt(Math.round(pf.capitalAn1))} · ${pf.rendementTotalAn1Pct.toFixed(1)} %`],370 [`Équité à 5 ans (appréciation ${pf.params.appreciationPct} %/an)`, `${fmt(Math.round(pf.equite5Ans))} · gain total ${fmt(Math.round(pf.gainTotal5Ans))}`],371 ["Loyer de point mort / marge de sécurité", `${fmt(Math.round(pf.loyerPointMort))} / porte · ${pf.margeSecuriteLoyerPct.toFixed(0)} %`],372 ["Liquidités requises (mise + mutation + notaire + inspection)", fmt(Math.round(pf.liquiditesRequises))],373 ], y);374 y = para(c,375 "Pro forma normalisé : loyer implicite dérivé de la valeur conclue et du TGA de référence — pas des baux réels. " +376 "Taxes approximées depuis le rôle. Ne constitue pas un conseil financier.", y);377 }378379 y = sectionTitle(c, "6.", "Réconciliation et conclusion", y + 4);380 const rh2 = 20;381 const recRows: [string, string, string][] = [382 ["Approche", "Indication de valeur", "Pondération"],383 ["Modèle hédonique de masse", fmt(r.modelEstimate), `${(r.modelWeight * 100).toFixed(0)} %`],384 ["Comparaison directe (médiane pondérée)", fmt(r.compsEstimate), `${((1 - r.modelWeight) * 100).toFixed(0)} %`],385 ["VALEUR CONCLUE", fmt(r.estimate), "100 %"],386 ];387 recRows.forEach(([a, b, cc], i) => {388 const head = i === 0;389 const last = i === recRows.length - 1;390 doc.rect(M, y, CW, rh2).fillAndStroke(head ? INK : last ? LIME : i % 2 ? SURFACE2 : "#ffffff", INK);391 doc.font(head || last ? "JB-Bold" : "Inter").fontSize(head ? 6.8 : last ? 8.6 : 8)392 .fillColor(head ? "#ffffff" : INK)393 .text(head ? a.toUpperCase() : a, M + 8, y + 6, { lineBreak: false });394 doc.text(b, M + CW * 0.52, y + 6, { lineBreak: false });395 doc.text(cc, M + CW * 0.84, y + 6, { lineBreak: false });396 y += rh2;397 });398 y += 10;399 y = para(c,400 `Compte tenu de la qualité de l'appariement des données, du nombre et de la cohérence des comparables (dispersion ±${r.compsDispersionPct ?? "—"} %) ` +401 `et de la performance validée du modèle, la valeur marchande estimée de la propriété au ${generated} est conclue à ` +402 `${fmt(r.estimate)}, à l'intérieur d'une fourchette raisonnable de ${fmt(r.low)} à ${fmt(r.high)}. Indice de fiabilité : ` +403 `${r.confidenceLevel} (${r.confidencePct} %) — ${CONF_TXT[r.confidenceLevel]}.`, y);404405 doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("6.1 Évolution de la valeur estimée (2021-2026)", M, y);406 y += 16;407 const hist = u.history.filter((h) => h.value != null);408 y = kvTable(c, hist.map((h) => [String(h.year), fmt(h.value)] as [string, string]), y, CW * 0.6);409410 /* ------------------- PAGE 6 : CONDITIONS + CERTIFICATION ------------------- */411 y = newPage(c, TOTAL);412 y = sectionTitle(c, "7.", "Hypothèses et conditions limitatives", y);413 const conds = [414 "Le présent rapport est généré automatiquement par le moteur d'évaluation statistique ValoPlex (modèle d'évaluation de masse, AVM). Il ne constitue pas un rapport d'évaluation au sens des normes de pratique professionnelle de l'Ordre des évaluateurs agréés du Québec (OEAQ) et ne peut être présenté comme tel.",415 "Aucune inspection physique de la propriété n'a été effectuée. L'état d'entretien, la qualité des finis, les rénovations, les dépendances et tout vice apparent ou caché ne sont pas considérés.",416 "Les caractéristiques utilisées proviennent du rôle d'évaluation foncière (MAMH, données ouvertes) et de transactions publiées. Leur exactitude et leur mise à jour ne sont pas garanties.",417 "Le droit évalué est présumé être la pleine propriété, libre de toute hypothèque, servitude, charge ou restriction inhabituelle. Aucune vérification de titres n'a été réalisée.",418 "La conformité aux lois, règlements de zonage et permis est présumée. Aucune étude environnementale n'a été effectuée ; la propriété est présumée exempte de contamination.",419 "La valeur conclue vaut à la date d'effet uniquement. Les marchés immobiliers évoluent ; l'estimation ne doit pas être utilisée à une autre date.",420 "La fourchette P10-P90 et l'indice de fiabilité font partie intégrante de la conclusion et doivent être communiqués avec elle.",421 "Ce rapport est destiné au destinataire indiqué en page titre, aux fins d'information et d'appui à une démarche de financement. Toute autre utilisation ou distribution engage la seule responsabilité de l'utilisateur.",422 "Pour une décision de financement finale, une confirmation par un évaluateur agréé (OEAQ) mandaté par l'institution financière est recommandée.",423 ];424 conds.forEach((txt, i) => {425 doc.font("JB-Bold").fontSize(7).fillColor(GREEN).text(`7.${i + 1}`, M, y + 1, { lineBreak: false });426 doc.font("Inter").fontSize(7.9).fillColor(INK2)427 .text(txt, M + 26, y, { width: CW - 26, lineGap: 1.6, align: "justify" });428 y = doc.y + 6;429 });430431 y = sectionTitle(c, "8.", "Attestation", y + 2);432 y = para(c,433 "J'atteste que les données, méthodes et pondérations décrites dans ce rapport sont celles réellement employées par le moteur " +434 "ValoPlex pour produire la conclusion de valeur ; que la méthodologie complète, ses métriques de validation et ses limites " +435 "sont publiées à www.valoplex.com/methodologie ; et que ni ValoPlex ni son exploitant n'ont d'intérêt présent ou futur dans la " +436 "propriété évaluée, ni de lien de dépendance avec les parties à une éventuelle transaction.", y);437 y += 14;438 doc.rect(M, y + 26, 200, 0.9).fill(INK);439 doc.font("SG-Bold").fontSize(10).fillColor(INK).text("ValoPlex", M, y + 32);440 doc.font("Inter").fontSize(8).fillColor(INK2)441 .text("Simon-Pierre Boucher · contact@spboucher.ai", M, y + 46)442 .text(`Dossier ${dossier} · ${generated}`, M, y + 58);443 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)444 .text("ANNEXE DISPONIBLE : RAPPORT STANDARD (PLAN DES COMPARABLES, LISTE COMPLÈTE DES VENTES) — WWW.VALOPLEX.COM", M, y + 82, { characterSpacing: 0.5 });445446 doc.end();447 return done;448}449