// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Rapport d'évaluation détaillé — format professionnel (présentation bancaire). * Structure calquée sur un rapport d'évaluation : page titre, sommaire exécutif, * mandat et portée, définition de la valeur marchande, méthodologie, description * de la propriété, grille d'ajustement des comparables, analyse hédonique, * réconciliation, hypothèses et conditions limitatives, certification. * Ton sobre, encadrés honnêtes : AVM statistique — n'est PAS un rapport OEAQ. */ import PDFDocument from "pdfkit"; import path from "path"; import type { UnitEstimate } from "./estimator"; import { buildProforma } from "./proforma"; const INK = "#141814"; const INK2 = "#4d5551"; const INK3 = "#8b928c"; const SURFACE2 = "#faf9f5"; const GREEN = "#b25f16"; const GREEN_DEEP = "#8a4710"; const LIME = "#ff9f45"; const W = 612; const H = 792; const M = 56; const CW = W - 2 * M; const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); const fmt = (v: number | null | undefined) => v == null ? "—" : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v); const signed = (v: number) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)))}`; const CONF_TXT: Record = { A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable — marché mince", }; type Doc = InstanceType; interface Ctx { doc: Doc; page: number; dossier: string; generated: string; } function header(c: Ctx) { const { doc } = c; doc.font("SG-Bold").fontSize(10).fillColor(INK).text("Valo", M, 26, { lineBreak: false }); const w = doc.widthOfString("Valo"); doc.rect(M + w + 2, 25, doc.widthOfString("Plex") + 6, 13).fill(INK); doc.fillColor(LIME).text("Plex", M + w + 5, 26, { lineBreak: false }); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) .text("RAPPORT D'ÉVALUATION DÉTAILLÉ — FORMAT PROFESSIONNEL", M + 70, 29, { characterSpacing: 0.8, lineBreak: false }); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) .text(`DOSSIER ${c.dossier}`, W - M - 160, 29, { width: 160, align: "right" }); doc.rect(M, 44, CW, 0.8).fill(INK); } function footer(c: Ctx, total: number) { const { doc } = c; doc.rect(M, H - 52, CW, 0.8).fill(INK); doc.font("JB-Reg").fontSize(6).fillColor(INK3).text( "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", M, H - 44, { width: CW - 50, characterSpacing: 0.3, lineBreak: false }); doc.font("JB-Bold").fontSize(7).fillColor(INK) .text(`${c.page} / ${total}`, W - M - 40, H - 45, { width: 40, align: "right" }); } function newPage(c: Ctx, total: number) { c.doc.addPage({ size: "LETTER", margin: 0 }); c.page += 1; c.doc.rect(0, 0, W, H).fill("#ffffff"); header(c); footer(c, total); return 62; // y de départ } function sectionTitle(c: Ctx, num: string, title: string, y: number): number { const { doc } = c; doc.font("JB-Bold").fontSize(8).fillColor(GREEN) .text(num, M, y + 2, { lineBreak: false }); doc.font("SG-Bold").fontSize(13).fillColor(INK) .text(title.toUpperCase(), M + 30, y, { lineBreak: false }); doc.rect(M, y + 19, CW, 1.4).fill(INK); doc.rect(M, y + 21.5, 60, 2.2).fill(LIME); return y + 32; } function para(c: Ctx, txt: string, y: number, opts?: { size?: number; color?: string; width?: number }): number { const { doc } = c; doc.font("Inter").fontSize(opts?.size ?? 8.6).fillColor(opts?.color ?? INK2) .text(txt, M, y, { width: opts?.width ?? CW, lineGap: 2.2, align: "justify" }); return doc.y + 8; } function kvTable(c: Ctx, rows: [string, string][], y: number, colW = CW): number { const { doc } = c; const rh = 17; rows.forEach(([k, v], i) => { const ry = y + i * rh; if (i % 2 === 0) doc.rect(M, ry, colW, rh).fill(SURFACE2); doc.font("JB-Bold").fontSize(6.6).fillColor(INK3) .text(k.toUpperCase(), M + 8, ry + 5, { characterSpacing: 0.5, lineBreak: false }); doc.font("SG-Bold").fontSize(8.6).fillColor(INK) .text(v, M + colW * 0.42, ry + 4, { width: colW * 0.58 - 10, height: 11, ellipsis: true }); }); doc.rect(M, y, colW, rows.length * rh).lineWidth(0.9).stroke(INK); return y + rows.length * rh + 10; } export async function buildProReport( data: UnitEstimate, marketIndex: { month: string; idx: number }[] ): Promise { const u = data.unit!; const s = u.specs; const r = data.result; const generated = new Date().toISOString().slice(0, 10); const dossier = `VP-${u.id.slice(0, 8)}-${generated.replaceAll("-", "")}`; const TOTAL = 6; const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: `Rapport d'évaluation détaillé — ${u.adresse ?? ""}` } }); doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf")); doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf")); doc.registerFont("Inter", F("Inter-Regular.ttf")); const chunks: Buffer[] = []; doc.on("data", (ch: Buffer) => chunks.push(ch)); const done = new Promise((res) => doc.on("end", () => res(Buffer.concat(chunks)))); const c: Ctx = { doc, page: 1, dossier, generated }; /* ------------------------------- PAGE TITRE ------------------------------- */ doc.rect(0, 0, W, H).fill("#ffffff"); doc.rect(0, 0, W, 6).fill(INK); doc.rect(0, 6, W, 2).fill(LIME); // logo sobre doc.font("SG-Bold").fontSize(26).fillColor(INK).text("Valo", M, 70, { lineBreak: false }); const lw = doc.widthOfString("Valo"); doc.rect(M + lw + 4, 68, doc.widthOfString("Plex") + 14, 32).fill(INK); doc.fillColor(LIME).text("Plex", M + lw + 11, 70, { lineBreak: false }); doc.font("JB-Reg").fontSize(7.5).fillColor(INK3) .text("MOTEUR D'ÉVALUATION IMMOBILIÈRE STATISTIQUE · QUÉBEC", M, 108, { characterSpacing: 1.2 }); doc.rect(M, 168, CW, 1.4).fill(INK); doc.font("SG-Bold").fontSize(30).fillColor(INK) .text("RAPPORT D'ÉVALUATION\nDÉTAILLÉ", M, 188, { lineGap: 4 }); doc.font("Inter").fontSize(10.5).fillColor(INK2) .text("Approche par comparaison directe et modélisation hédonique de masse", M, 262); doc.rect(M, 288, CW, 1.4).fill(INK); // bloc propriété doc.font("JB-Bold").fontSize(7.5).fillColor(INK3).text("PROPRIÉTÉ ÉVALUÉE", M, 318, { characterSpacing: 1.2 }); doc.font("SG-Bold").fontSize(17).fillColor(INK) .text(`${u.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}`, M, 332); doc.font("SG-Med").fontSize(12).fillColor(INK2) .text(`${u.municipalite ?? ""} (Québec) · Matricule ${s.matricule ?? "—"}`, M, doc.y + 2); // encadré conclusion const by = 420; doc.roundedRect(M, by, CW, 128, 6).lineWidth(1.6).fillAndStroke(SURFACE2, INK); doc.font("JB-Bold").fontSize(7.5).fillColor(GREEN_DEEP) .text("CONCLUSION DE LA VALEUR MARCHANDE ESTIMÉE", M + 20, by + 16, { characterSpacing: 1.2 }); doc.font("SG-Bold").fontSize(38).fillColor(INK).text(fmt(r.estimate), M + 20, by + 32); doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FOURCHETTE (P10-P90)", M + 20, by + 84, { lineBreak: false }); doc.font("SG-Bold").fontSize(11).fillColor(INK).text(`${fmt(r.low)} → ${fmt(r.high)}`, M + 20, by + 96, { lineBreak: false }); doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("DATE D'EFFET", M + 260, by + 84, { lineBreak: false }); doc.font("SG-Bold").fontSize(11).fillColor(INK).text(generated, M + 260, by + 96, { lineBreak: false }); doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FIABILITÉ", M + 380, by + 84, { lineBreak: false }); doc.font("SG-Bold").fontSize(11).fillColor(INK) .text(`${r.confidenceLevel} · ${r.confidencePct} %`, M + 380, by + 96, { lineBreak: false }); // préparé pour / par doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("PRÉPARÉ POUR", M, 585, { characterSpacing: 1 }); doc.rect(M, 610, 220, 0.9).fill(INK2); doc.font("Inter").fontSize(7).fillColor(INK3).text("(destinataire / institution financière)", M, 615); doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("PRÉPARÉ PAR", M + 300, 585, { characterSpacing: 1 }); doc.font("SG-Bold").fontSize(10).fillColor(INK).text("ValoPlex — moteur d'évaluation statistique", M + 300, 597); doc.font("Inter").fontSize(8).fillColor(INK2).text("Simon-Pierre Boucher · contact@spboucher.ai", M + 300, 611); doc.font("Inter").fontSize(8).fillColor(INK2).text(`Dossier ${dossier} · Généré le ${generated}`, M + 300, 623); // avis doc.roundedRect(M, 668, CW, 54, 5).lineWidth(1).stroke(INK); doc.font("JB-Bold").fontSize(6.5).fillColor(INK).text("AVIS IMPORTANT", M + 12, 678, { characterSpacing: 1 }); doc.font("Inter").fontSize(7.4).fillColor(INK2).text( "Ce rapport est produit automatiquement par un modèle d'évaluation de masse (AVM). Il est structuré selon les usages de la " + "pratique de l'évaluation afin d'en faciliter la lecture, mais il ne constitue pas un rapport d'évaluation préparé par un " + "évaluateur agréé (OEAQ) et aucune inspection de la propriété n'a été effectuée. Voir les hypothèses et conditions limitatives, section 7.", M + 12, 689, { width: CW - 24, lineGap: 1.5 }); footer(c, TOTAL); /* ------------------------- PAGE 2 : SOMMAIRE EXÉCUTIF ------------------------- */ let y = newPage(c, TOTAL); y = sectionTitle(c, "1.", "Sommaire exécutif", y); y = kvTable(c, [ ["Adresse", `${u.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}, ${u.municipalite ?? ""}`], ["Identifiant provincial", u.id], ["Matricule", s.matricule ?? "—"], ["Usage prédominant (CUBF)", `${s.cubf ?? "—"} — ${s.cubfLibelle ?? "—"}`], ["Droit évalué (présumé)", "Pleine propriété, libre de toute charge"], ["Date d'effet de l'estimation", generated], ["Méthodes utilisées", "Comparaison directe plex (ajustée) et modèle hédonique spécialisé"], ["Valeur estimée", fmt(r.estimate)], ["Fourchette P10-P90", `${fmt(r.low)} → ${fmt(r.high)}`], ["Indice de fiabilité", `${r.confidenceLevel} (${r.confidencePct} %) — ${CONF_TXT[r.confidenceLevel]}`], ["Évaluation municipale (rôle 2026)", fmt(u.valeurRole)], ["Écart estimation / rôle", u.valeurRole ? `${((r.estimate / u.valeurRole - 1) * 100).toFixed(1)} %` : "—"], ], y); y = para(c, "La valeur conclue résulte de la réconciliation pondérée de deux approches indépendantes : un modèle hédonique de " + `masse entraîné sur environ 690 000 transactions québécoises (pondération ${(r.modelWeight * 100).toFixed(0)} %) et l'analyse de ` + `${r.nCompsUsed} ventes comparables récentes situées à proximité du sujet, individuellement ajustées pour les conditions de ` + `marché, la superficie habitable et l'âge (pondération ${((1 - r.modelWeight) * 100).toFixed(0)} %). La dispersion des comparables ` + `ajustés est de ±${r.compsDispersionPct ?? "—"} %. L'indice de fiabilité intègre le nombre de comparables, leur cohérence et la ` + "largeur de la fourchette d'incertitude du modèle.", y); y = sectionTitle(c, "2.", "Mandat, portée et définition de la valeur", y + 4); y = para(c, "Mandat — Estimer la valeur marchande la plus probable de la propriété identifiée ci-dessus, à la date d'effet, aux fins " + "d'information et d'appui à une démarche de financement. Portée — Analyse automatisée des données publiques du rôle " + "d'évaluation foncière (MAMH) et des transactions publiées ; aucune inspection, aucune vérification de titres.", y); y = para(c, "Définition de la valeur marchande — Le prix le plus probable, à la date d'effet, moyennant paiement comptant ou " + "conditions équivalentes, pour lequel la propriété devrait se vendre sur un marché concurrentiel et ouvert, dans le cadre " + "d'une transaction sans lien de dépendance entre un acheteur et un vendeur prudents et informés, aucune des parties " + "n'étant contrainte d'agir et un délai de mise en marché raisonnable étant accordé.", y); y = para(c, "Approches écartées — L'approche du coût n'est pas appliquée (absence de données fiables sur le coût de remplacement " + "déprécié sans inspection). L'approche du revenu n'est pas appliquée (propriété à vocation principalement résidentielle ; " + "données de loyers non disponibles dans les sources publiques utilisées).", y); /* --------------------- PAGE 3 : DESCRIPTION DE LA PROPRIÉTÉ --------------------- */ y = newPage(c, TOTAL); y = sectionTitle(c, "3.", "Description de la propriété", y); doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.1 Bâtiment", M, y); y += 16; y = kvTable(c, [ ["Année de construction", u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? " (estimée au rôle)" : " (réelle)"}` : "—"], ["Aire d'étages (habitable)", u.aireEtagesM2 ? `${u.aireEtagesM2} m² (${Math.round(u.aireEtagesM2 * 10.7639)} pi²)` : "—"], ["Nombre d'étages", s.nbEtages != null ? String(s.nbEtages) : "—"], ["Genre de construction", s.genreConstruction ?? "—"], ["Lien physique", s.lienPhysique ?? "—"], ["Nombre de logements", u.nbLogements != null ? String(u.nbLogements) : "—"], ["Locaux non résidentiels", s.nbLocauxNonResid != null ? String(s.nbLocauxNonResid) : "—"], ], y); doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.2 Terrain", M, y); y += 16; y = kvTable(c, [ ["Superficie du terrain", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m² (${Math.round(u.superficieTerrainM2 * 10.7639)} pi²)` : "—"], ["Mesure frontale", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"], ["Unité de voisinage (rôle)", s.uniteVoisinage || "—"], ["Arrondissement", s.arrond || "—"], ], y); doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.3 Données du rôle d'évaluation", M, y); y += 16; y = kvTable(c, [ ["Valeur du terrain (rôle 2026)", fmt(s.valeurTerrain)], ["Valeur du bâtiment (rôle 2026)", fmt(s.valeurBatiment)], ["Valeur totale (rôle 2026)", fmt(u.valeurRole)], ["Valeur au rôle antérieur", fmt(s.valeurAnterieure)], ["Conditions du marché considérées au", s.datCondMarche ?? "—"], ], y); y = para(c, "Les caractéristiques ci-dessus proviennent du rôle d'évaluation foncière en vigueur (source : MAMH, données ouvertes). " + "Elles n'ont pas fait l'objet d'une vérification sur place.", y); /* --------------------- PAGE 4 : GRILLE DES COMPARABLES --------------------- */ y = newPage(c, TOTAL); y = sectionTitle(c, "4.", "Approche par comparaison — grille d'ajustement", y); const comps = r.comps.slice(0, 4); const colW = (CW - 118) / comps.length; const rowsG: [string, (ci: number) => string][] = [ ["Adresse", (i) => `${comps[i].street ?? ""}`], ["Municipalité", (i) => comps[i].city ?? "—"], ["Date de vente", (i) => comps[i].date], ["Prix de vente", (i) => fmt(comps[i].amount)], ["Distance du sujet", (i) => comps[i].distanceM < 1000 ? `${Math.round(comps[i].distanceM)} m` : `${(comps[i].distanceM / 1000).toFixed(1)} km`], ["Aire d'étages", (i) => comps[i].floorArea ? `${comps[i].floorArea} m²` : "—"], ["Année constr.", (i) => comps[i].yearBuilt ? String(comps[i].yearBuilt) : "—"], ["Ajust. conditions de marché", (i) => signed(comps[i].adjTime)], ["Ajust. superficie", (i) => signed(comps[i].adjArea)], ["Ajust. âge", (i) => signed(comps[i].adjAge)], ["PRIX AJUSTÉ", (i) => fmt(Math.round(comps[i].adjustedPrice))], ["Pondération", (i) => `${(comps[i].weight * 100).toFixed(0)} %`], ]; const rh = 20; // en-têtes colonnes doc.rect(M, y, 118, rh).fill(INK); doc.font("JB-Bold").fontSize(6.4).fillColor("#ffffff").text("ÉLÉMENT", M + 6, y + 7, { lineBreak: false }); comps.forEach((_, i) => { doc.rect(M + 118 + i * colW, y, colW, rh).fillAndStroke(i % 2 ? SURFACE2 : "#ffffff", INK); doc.font("JB-Bold").fontSize(6.6).fillColor(GREEN_DEEP) .text(`COMPARABLE ${i + 1}`, M + 118 + i * colW + 6, y + 7, { lineBreak: false }); }); y += rh; rowsG.forEach(([label, get]) => { const bold = label === "PRIX AJUSTÉ"; doc.rect(M, y, 118, rh).fillAndStroke(bold ? LIME : SURFACE2, INK); doc.font("JB-Bold").fontSize(6).fillColor(INK) .text(label.toUpperCase(), M + 6, y + 7, { width: 108, height: 8, ellipsis: true, lineBreak: false }); comps.forEach((_, ci) => { doc.rect(M + 118 + ci * colW, y, colW, rh).fillAndStroke(bold ? "#f6fbe4" : "#ffffff", INK); doc.font(bold ? "SG-Bold" : "Inter").fontSize(bold ? 8.4 : 7.4).fillColor(bold ? INK : INK2) .text(get(ci), M + 118 + ci * colW + 6, y + (bold ? 5.5 : 6.5), { width: colW - 12, height: 10, ellipsis: true, lineBreak: false }); }); y += rh; }); y += 10; y = para(c, `${r.nCompsUsed} ventes comparables ont été retenues au total (les ${comps.length} plus pondérées sont détaillées ci-dessus ; la liste ` + "complète figure dans le rapport standard). Chaque prix de vente est ajusté : (i) aux conditions actuelles du marché au moyen " + "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 " + "prix unitaire au m² du comparable, plafonné à ±25 %, et (iii) pour l'écart d'âge à raison de 0,5 % par année, plafonné à ±10 %. " + `La médiane pondérée des prix ajustés s'établit à ${fmt(r.compsEstimate)}.`, y); // mini-indice de marché if (marketIndex.length > 3) { doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("4.1 Indice des conditions de marché du segment", M, y); y += 16; const gw = CW, gh = 84; doc.roundedRect(M, y, gw, gh, 5).lineWidth(1).fillAndStroke("#ffffff", INK); const vals = marketIndex.map((p) => p.idx); const mn = Math.min(...vals), mx = Math.max(...vals), span = mx - mn || 1; const pts = marketIndex.map((p, i) => [ M + 14 + (i / (marketIndex.length - 1)) * (gw - 28), y + gh - 18 - ((p.idx - mn) / span) * (gh - 36), ]); doc.moveTo(pts[0][0], pts[0][1]); pts.slice(1).forEach(([px, py]) => doc.lineTo(px, py)); doc.lineWidth(1.8).stroke(GREEN); const last = pts[pts.length - 1]; doc.circle(last[0], last[1], 3).lineWidth(1.2).fillAndStroke(LIME, INK); doc.font("JB-Reg").fontSize(6).fillColor(INK3) .text(marketIndex[0].month, M + 14, y + gh - 12, { lineBreak: false }) .text(marketIndex[marketIndex.length - 1].month, M + gw - 60, y + gh - 12, { width: 46, align: "right" }); doc.font("JB-Reg").fontSize(6).fillColor(INK3) .text(`BASE 1,00 = ${marketIndex[marketIndex.length - 1].month} · MÉDIANE $/M² LISSÉE 3 MOIS`, M + 14, y + 8, { characterSpacing: 0.5 }); } /* ------------------- PAGE 5 : HÉDONIQUE + RÉCONCILIATION ------------------- */ y = newPage(c, TOTAL); y = sectionTitle(c, "5.", "Analyse hédonique de masse", y); y = para(c, "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 " + "au rôle d'évaluation foncière géoréférencé. Vingt-cinq variables décrivent chaque plex (portes, aire par porte, mixité, âge, " + "localisation fine, valeurs au rôle par porte, temps). Sur un échantillon réservé de 13 617 ventes jamais vues, l'erreur " + "médiane est de 14,0 % et 65 % des estimations tombent à ±20 % du prix réel (ratio médian 0,973 — " + "validation selon la norme IAAO). L'incertitude propre à l'immeuble est quantifiée par deux modèles de " + "régression quantile (10e et 90e centiles), puis CALIBRÉE par méthode conforme : couverture de 80 % vérifiée empiriquement.", y); y = kvTable(c, [ ["Estimation du modèle hédonique", fmt(r.modelEstimate)], ["Intervalle P10-P90 du modèle", `${fmt(r.low)} → ${fmt(r.high)} (recentré)`], ["Erreur médiane du modèle (validation)", "14,0 % (MdAPE, holdout plex)"], ], y); // pro forma indicatif (approche revenu inversée) const doorsN = u.nbLogements ?? 0; if (doorsN >= 2 && r.estimate > 0) { const pf = buildProforma(r.estimate, doorsN, u.valeurRole, u.municipalite); doc.font("SG-Bold").fontSize(9.5).fillColor(INK) .text("5.1 Pro forma indicatif (approche revenu inversée)", M, y); y += 16; y = kvTable(c, [ ["Loyer moyen implicite / porte / mois", fmt(Math.round(pf.loyerMoyenMensuel))], [`Revenus bruts (${doorsN} portes)`, fmt(Math.round(pf.revenusBruts))], ["Revenu net d'exploitation (RNE)", fmt(Math.round(pf.rne))], ["TGA implicite / MRB", `${pf.tgaImplicitePct.toFixed(2)} % / × ${pf.mrb.toFixed(1)}`], [`Paiement hypothécaire (${pf.params.tauxHypoPct} %, ${pf.params.amortAns} ans, mise ${pf.params.miseDeFondsPct} %)`, `${fmt(Math.round(pf.paiementMensuelHypo))} / mois`], ["Cashflow avant impôt / DSCR", `${fmt(Math.round(pf.cashflowAnnuel))} / an · DSCR ${pf.dscr.toFixed(2)}`], ["Capital remboursé an 1 / rendement global an 1", `${fmt(Math.round(pf.capitalAn1))} · ${pf.rendementTotalAn1Pct.toFixed(1)} %`], [`Équité à 5 ans (appréciation ${pf.params.appreciationPct} %/an)`, `${fmt(Math.round(pf.equite5Ans))} · gain total ${fmt(Math.round(pf.gainTotal5Ans))}`], ["Loyer de point mort / marge de sécurité", `${fmt(Math.round(pf.loyerPointMort))} / porte · ${pf.margeSecuriteLoyerPct.toFixed(0)} %`], ["Liquidités requises (mise + mutation + notaire + inspection)", fmt(Math.round(pf.liquiditesRequises))], ], y); y = para(c, "Pro forma normalisé : loyer implicite dérivé de la valeur conclue et du TGA de référence — pas des baux réels. " + "Taxes approximées depuis le rôle. Ne constitue pas un conseil financier.", y); } y = sectionTitle(c, "6.", "Réconciliation et conclusion", y + 4); const rh2 = 20; const recRows: [string, string, string][] = [ ["Approche", "Indication de valeur", "Pondération"], ["Modèle hédonique de masse", fmt(r.modelEstimate), `${(r.modelWeight * 100).toFixed(0)} %`], ["Comparaison directe (médiane pondérée)", fmt(r.compsEstimate), `${((1 - r.modelWeight) * 100).toFixed(0)} %`], ["VALEUR CONCLUE", fmt(r.estimate), "100 %"], ]; recRows.forEach(([a, b, cc], i) => { const head = i === 0; const last = i === recRows.length - 1; doc.rect(M, y, CW, rh2).fillAndStroke(head ? INK : last ? LIME : i % 2 ? SURFACE2 : "#ffffff", INK); doc.font(head || last ? "JB-Bold" : "Inter").fontSize(head ? 6.8 : last ? 8.6 : 8) .fillColor(head ? "#ffffff" : INK) .text(head ? a.toUpperCase() : a, M + 8, y + 6, { lineBreak: false }); doc.text(b, M + CW * 0.52, y + 6, { lineBreak: false }); doc.text(cc, M + CW * 0.84, y + 6, { lineBreak: false }); y += rh2; }); y += 10; y = para(c, `Compte tenu de la qualité de l'appariement des données, du nombre et de la cohérence des comparables (dispersion ±${r.compsDispersionPct ?? "—"} %) ` + `et de la performance validée du modèle, la valeur marchande estimée de la propriété au ${generated} est conclue à ` + `${fmt(r.estimate)}, à l'intérieur d'une fourchette raisonnable de ${fmt(r.low)} à ${fmt(r.high)}. Indice de fiabilité : ` + `${r.confidenceLevel} (${r.confidencePct} %) — ${CONF_TXT[r.confidenceLevel]}.`, y); doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("6.1 Évolution de la valeur estimée (2021-2026)", M, y); y += 16; const hist = u.history.filter((h) => h.value != null); y = kvTable(c, hist.map((h) => [String(h.year), fmt(h.value)] as [string, string]), y, CW * 0.6); /* ------------------- PAGE 6 : CONDITIONS + CERTIFICATION ------------------- */ y = newPage(c, TOTAL); y = sectionTitle(c, "7.", "Hypothèses et conditions limitatives", y); const conds = [ "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.", "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.", "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.", "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.", "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.", "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.", "La fourchette P10-P90 et l'indice de fiabilité font partie intégrante de la conclusion et doivent être communiqués avec elle.", "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.", "Pour une décision de financement finale, une confirmation par un évaluateur agréé (OEAQ) mandaté par l'institution financière est recommandée.", ]; conds.forEach((txt, i) => { doc.font("JB-Bold").fontSize(7).fillColor(GREEN).text(`7.${i + 1}`, M, y + 1, { lineBreak: false }); doc.font("Inter").fontSize(7.9).fillColor(INK2) .text(txt, M + 26, y, { width: CW - 26, lineGap: 1.6, align: "justify" }); y = doc.y + 6; }); y = sectionTitle(c, "8.", "Attestation", y + 2); y = para(c, "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 " + "ValoPlex pour produire la conclusion de valeur ; que la méthodologie complète, ses métriques de validation et ses limites " + "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 " + "propriété évaluée, ni de lien de dépendance avec les parties à une éventuelle transaction.", y); y += 14; doc.rect(M, y + 26, 200, 0.9).fill(INK); doc.font("SG-Bold").fontSize(10).fillColor(INK).text("ValoPlex", M, y + 32); doc.font("Inter").fontSize(8).fillColor(INK2) .text("Simon-Pierre Boucher · contact@spboucher.ai", M, y + 46) .text(`Dossier ${dossier} · ${generated}`, M, y + 58); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) .text("ANNEXE DISPONIBLE : RAPPORT STANDARD (PLAN DES COMPARABLES, LISTE COMPLÈTE DES VENTES) — WWW.VALOPLEX.COM", M, y + 82, { characterSpacing: 0.5 }); doc.end(); return done; }