SPB Git forge

spb/uqo-eval

Public
55commits 2branches 0releases
134.4 MBsize
maindefault branch
yesterdaylast push
TypeScript 90.1% JavaScript 3.2% Python 3.2% CSS 1.8% HTML 1.8%
25.9 KB · 436 lines typescript
Raw Blame History
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2/**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 * Généré dans le cadre des cours IMM1003 et IMM1033 (UQO), avec logo officiel UQO.10 */11import PDFDocument from "pdfkit";12import path from "path";13import type { UnitEstimate } from "./estimator";1415const INK = "#16181b";16const INK2 = "#4d5551";17const INK3 = "#8b928c";18const SURFACE2 = "#f9fafa";19const GREEN = "#005F85"; // bleu UQO (PMS 7469)20const GREEN_DEEP = "#00465F";21const LIME = "#2BCCAE"; // turquoise UQO2223const 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 UQO_LOGO_BLEU = path.join(process.cwd(), "assets", "uqo", "UQO_couleur.png");30const UQO_RATIO = 2123 / 853; // ratio natif du logo — jamais déformé3132/** Mention pédagogique obligatoire sur tous les rapports UQO Éval. */33const COURS =34  "Rapport généré dans le cadre des cours IMM1003 — Éléments d'évaluation immobilière et " +35  "IMM1033 — Méthodes du coût en évaluation immobilière, enseignés par Simon-Pierre Boucher (UQO).";36const fmt = (v: number | null | undefined) =>37  v == null38    ? "—"39    : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);40const signed = (v: number) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)))}`;41const CONF_TXT: Record<string, string> = {42  A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable — marché mince",43};4445type Doc = InstanceType<typeof PDFDocument>;4647interface Ctx {48  doc: Doc;49  page: number;50  dossier: string;51  generated: string;52}5354function header(c: Ctx) {55  const { doc } = c;56  doc.image(UQO_LOGO_BLEU, M, 22, { height: 14 });57  doc.font("SG-Bold").fontSize(10).fillColor(INK)58    .text("Éval", M + 14 * UQO_RATIO + 4, 26, { lineBreak: false });59  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)60    .text("RAPPORT D'ÉVALUATION DÉTAILLÉ — CADRE PÉDAGOGIQUE UQO", M + 90, 29, { characterSpacing: 0.8, lineBreak: false });61  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)62    .text(`DOSSIER ${c.dossier}`, W - M - 160, 29, { width: 160, align: "right" });63  doc.rect(M, 44, CW, 0.8).fill(INK);64}6566function footer(c: Ctx, total: number) {67  const { doc } = c;68  doc.rect(M, H - 52, CW, 0.8).fill(INK);69  doc.font("JB-Reg").fontSize(6).fillColor(INK3).text(70    "AVM PÉDAGOGIQUE — NE CONSTITUE PAS UN RAPPORT D'ÉVALUATION AU SENS DES NORMES DE L'OEAQ · UQO-EVAL.APP",71    M, H - 44, { width: CW - 50, characterSpacing: 0.3, lineBreak: false });72  doc.font("JB-Reg").fontSize(6).fillColor(INK3).text(73    "COURS IMM1003 · IMM1033 — SIMON-PIERRE BOUCHER, UQO",74    M, H - 35, { width: CW - 50, characterSpacing: 0.3, lineBreak: false });75  doc.font("JB-Bold").fontSize(7).fillColor(INK)76    .text(`${c.page} / ${total}`, W - M - 40, H - 45, { width: 40, align: "right" });77}7879function newPage(c: Ctx, total: number) {80  c.doc.addPage({ size: "LETTER", margin: 0 });81  c.page += 1;82  c.doc.rect(0, 0, W, H).fill("#ffffff");83  header(c);84  footer(c, total);85  return 62; // y de départ86}8788function sectionTitle(c: Ctx, num: string, title: string, y: number): number {89  const { doc } = c;90  doc.font("JB-Bold").fontSize(8).fillColor(GREEN)91    .text(num, M, y + 2, { lineBreak: false });92  doc.font("SG-Bold").fontSize(13).fillColor(INK)93    .text(title.toUpperCase(), M + 30, y, { lineBreak: false });94  doc.rect(M, y + 19, CW, 1.4).fill(INK);95  doc.rect(M, y + 21.5, 60, 2.2).fill(LIME);96  return y + 32;97}9899function para(c: Ctx, txt: string, y: number, opts?: { size?: number; color?: string; width?: number }): number {100  const { doc } = c;101  doc.font("Inter").fontSize(opts?.size ?? 8.6).fillColor(opts?.color ?? INK2)102    .text(txt, M, y, { width: opts?.width ?? CW, lineGap: 2.2, align: "justify" });103  return doc.y + 8;104}105106function kvTable(c: Ctx, rows: [string, string][], y: number, colW = CW): number {107  const { doc } = c;108  const rh = 17;109  rows.forEach(([k, v], i) => {110    const ry = y + i * rh;111    if (i % 2 === 0) doc.rect(M, ry, colW, rh).fill(SURFACE2);112    doc.font("JB-Bold").fontSize(6.6).fillColor(INK3)113      .text(k.toUpperCase(), M + 8, ry + 5, { characterSpacing: 0.5, lineBreak: false });114    doc.font("SG-Bold").fontSize(8.6).fillColor(INK)115      .text(v, M + colW * 0.42, ry + 4, { width: colW * 0.58 - 10, height: 11, ellipsis: true });116  });117  doc.rect(M, y, colW, rows.length * rh).lineWidth(0.9).stroke(INK);118  return y + rows.length * rh + 10;119}120121export async function buildProReport(122  data: UnitEstimate,123  marketIndex: { month: string; idx: number }[]124): Promise<Buffer> {125  const u = data.unit!;126  const s = u.specs;127  const r = data.result;128  const generated = new Date().toISOString().slice(0, 10);129  const dossier = `UQO-${u.id.slice(0, 8)}-${generated.replaceAll("-", "")}`;130  const TOTAL = 6;131132  const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: `UQO Éval — Rapport d'évaluation détaillé — ${u.adresse ?? ""}`, Author: "UQO Éval — cours IMM1003 · IMM1033 (Simon-Pierre Boucher, UQO)" } });133  doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));134  doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));135  doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));136  doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));137  doc.registerFont("Inter", F("Inter-Regular.ttf"));138139  const chunks: Buffer[] = [];140  doc.on("data", (ch: Buffer) => chunks.push(ch));141  const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));142  const c: Ctx = { doc, page: 1, dossier, generated };143144  /* ------------------------------- PAGE TITRE ------------------------------- */145  doc.rect(0, 0, W, H).fill("#ffffff");146  doc.rect(0, 0, W, 6).fill(INK);147  doc.rect(0, 6, W, 2).fill(LIME);148  // logo officiel UQO (bleu PMS 7469, proportions natives) + wordmark Éval149  doc.image(UQO_LOGO_BLEU, M, 62, { height: 38 });150  doc.font("SG-Bold").fontSize(26).fillColor(INK)151    .text("Éval", M + 38 * UQO_RATIO + 10, 72, { lineBreak: false });152  doc.font("JB-Reg").fontSize(7.5).fillColor(INK3)153    .text("LABORATOIRE PÉDAGOGIQUE D'ÉVALUATION IMMOBILIÈRE · UNIVERSITÉ DU QUÉBEC EN OUTAOUAIS", M, 108, { characterSpacing: 1.2 });154  doc.font("JB-Bold").fontSize(7).fillColor(GREEN)155    .text("COURS IMM1003 — ÉLÉMENTS D'ÉVALUATION IMMOBILIÈRE · IMM1033 — MÉTHODES DU COÛT EN ÉVALUATION IMMOBILIÈRE", M, 122, { characterSpacing: 0.6 });156157  doc.rect(M, 168, CW, 1.4).fill(INK);158  doc.font("SG-Bold").fontSize(30).fillColor(INK)159    .text("RAPPORT D'ÉVALUATION\nDÉTAILLÉ", M, 188, { lineGap: 4 });160  doc.font("Inter").fontSize(10.5).fillColor(INK2)161    .text("Approche par comparaison directe et modélisation hédonique de masse", M, 262);162  doc.rect(M, 288, CW, 1.4).fill(INK);163164  // bloc propriété165  doc.font("JB-Bold").fontSize(7.5).fillColor(INK3).text("PROPRIÉTÉ ÉVALUÉE", M, 318, { characterSpacing: 1.2 });166  doc.font("SG-Bold").fontSize(17).fillColor(INK)167    .text(`${u.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}`, M, 332);168  doc.font("SG-Med").fontSize(12).fillColor(INK2)169    .text(`${u.municipalite ?? ""} (Québec) · Matricule ${s.matricule ?? "—"}`, M, doc.y + 2);170171  // encadré conclusion172  const by = 420;173  doc.roundedRect(M, by, CW, 128, 6).lineWidth(1.6).fillAndStroke(SURFACE2, INK);174  doc.font("JB-Bold").fontSize(7.5).fillColor(GREEN_DEEP)175    .text("CONCLUSION DE LA VALEUR MARCHANDE ESTIMÉE", M + 20, by + 16, { characterSpacing: 1.2 });176  doc.font("SG-Bold").fontSize(38).fillColor(INK).text(fmt(r.estimate), M + 20, by + 32);177  doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FOURCHETTE (P10-P90)", M + 20, by + 84, { lineBreak: false });178  doc.font("SG-Bold").fontSize(11).fillColor(INK).text(`${fmt(r.low)}  →  ${fmt(r.high)}`, M + 20, by + 96, { lineBreak: false });179  doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("DATE D'EFFET", M + 260, by + 84, { lineBreak: false });180  doc.font("SG-Bold").fontSize(11).fillColor(INK).text(generated, M + 260, by + 96, { lineBreak: false });181  doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("FIABILITÉ", M + 380, by + 84, { lineBreak: false });182  doc.font("SG-Bold").fontSize(11).fillColor(INK)183    .text(`${r.confidenceLevel} · ${r.confidencePct} %`, M + 380, by + 96, { lineBreak: false });184185  // préparé pour / par186  doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("PRÉPARÉ POUR", M, 585, { characterSpacing: 1 });187  doc.rect(M, 610, 220, 0.9).fill(INK2);188  doc.font("Inter").fontSize(7).fillColor(INK3).text("(étudiante ou étudiant / groupe-cours)", M, 615);189  doc.font("JB-Bold").fontSize(7).fillColor(INK3).text("PRÉPARÉ PAR", M + 300, 585, { characterSpacing: 1 });190  doc.font("SG-Bold").fontSize(10).fillColor(INK).text("UQO Éval — moteur d'évaluation statistique", M + 300, 597);191  doc.font("Inter").fontSize(8).fillColor(INK2).text("Simon-Pierre Boucher, enseignant (UQO)", M + 300, 611);192  doc.font("Inter").fontSize(8).fillColor(INK2).text(`Dossier ${dossier} · Généré le ${generated}`, M + 300, 623);193194  // avis195  doc.roundedRect(M, 668, CW, 54, 5).lineWidth(1).stroke(INK);196  doc.font("JB-Bold").fontSize(6.5).fillColor(INK).text("AVIS IMPORTANT", M + 12, 678, { characterSpacing: 1 });197  doc.font("Inter").fontSize(7.4).fillColor(INK2).text(198    "Ce rapport est produit automatiquement par un modèle d'évaluation de masse (AVM). Il est structuré selon les usages de la " +199    "pratique de l'évaluation afin d'en faciliter la lecture, mais il ne constitue pas un rapport d'évaluation préparé par un " +200    "évaluateur agréé (OEAQ) et aucune inspection de la propriété n'a été effectuée. Voir les hypothèses et conditions limitatives, section 7.",201    M + 12, 689, { width: CW - 24, lineGap: 1.5 });202  footer(c, TOTAL);203204  /* ------------------------- PAGE 2 : SOMMAIRE EXÉCUTIF ------------------------- */205  let y = newPage(c, TOTAL);206  y = sectionTitle(c, "1.", "Sommaire exécutif", y);207  y = kvTable(c, [208    ["Adresse", `${u.adresse ?? ""}${s.apt ? ` app. ${s.apt}` : ""}, ${u.municipalite ?? ""}`],209    ["Identifiant provincial", u.id],210    ["Matricule", s.matricule ?? "—"],211    ["Usage prédominant (CUBF)", `${s.cubf ?? "—"} — ${s.cubfLibelle ?? "—"}`],212    ["Droit évalué (présumé)", "Pleine propriété, libre de toute charge"],213    ["Date d'effet de l'estimation", generated],214    ["Méthodes utilisées", "Comparaison directe (ajustée) et modèle hédonique de masse"],215    ["Valeur estimée", fmt(r.estimate)],216    ["Fourchette P10-P90", `${fmt(r.low)} → ${fmt(r.high)}`],217    ["Indice de fiabilité", `${r.confidenceLevel} (${r.confidencePct} %) — ${CONF_TXT[r.confidenceLevel]}`],218    ["Évaluation municipale (rôle 2026)", fmt(u.valeurRole)],219    ["Écart estimation / rôle", u.valeurRole ? `${((r.estimate / u.valeurRole - 1) * 100).toFixed(1)} %` : "—"],220  ], y);221  y = para(c,222    "La valeur conclue résulte de la réconciliation pondérée de deux approches indépendantes : un modèle hédonique de " +223    `masse entraîné sur environ 690 000 transactions québécoises (pondération ${(r.modelWeight * 100).toFixed(0)} %) et l'analyse de ` +224    `${r.nCompsUsed} ventes comparables récentes situées à proximité du sujet, individuellement ajustées pour les conditions de ` +225    `marché, la superficie habitable et l'âge (pondération ${((1 - r.modelWeight) * 100).toFixed(0)} %). La dispersion des comparables ` +226    `ajustés est de ±${r.compsDispersionPct ?? "—"} %. L'indice de fiabilité intègre le nombre de comparables, leur cohérence et la ` +227    "largeur de la fourchette d'incertitude du modèle.", y);228229  y = sectionTitle(c, "2.", "Mandat, portée et définition de la valeur", y + 4);230  y = para(c,231    "Mandat — Estimer la valeur marchande la plus probable de la propriété identifiée ci-dessus, à la date d'effet, aux fins " +232    "d'information et d'appui à une démarche de financement. Portée — Analyse automatisée des données publiques du rôle " +233    "d'évaluation foncière (MAMH) et des transactions publiées ; aucune inspection, aucune vérification de titres.", y);234  y = para(c,235    "Définition de la valeur marchande — Le prix le plus probable, à la date d'effet, moyennant paiement comptant ou " +236    "conditions équivalentes, pour lequel la propriété devrait se vendre sur un marché concurrentiel et ouvert, dans le cadre " +237    "d'une transaction sans lien de dépendance entre un acheteur et un vendeur prudents et informés, aucune des parties " +238    "n'étant contrainte d'agir et un délai de mise en marché raisonnable étant accordé.", y);239  y = para(c,240    "Approches écartées — L'approche du coût n'est pas appliquée (absence de données fiables sur le coût de remplacement " +241    "déprécié sans inspection). L'approche du revenu n'est pas appliquée (propriété à vocation principalement résidentielle ; " +242    "données de loyers non disponibles dans les sources publiques utilisées).", y);243244  /* --------------------- PAGE 3 : DESCRIPTION DE LA PROPRIÉTÉ --------------------- */245  y = newPage(c, TOTAL);246  y = sectionTitle(c, "3.", "Description de la propriété", y);247  doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.1 Bâtiment", M, y); y += 16;248  y = kvTable(c, [249    ["Année de construction", u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? " (estimée au rôle)" : " (réelle)"}` : "—"],250    ["Aire d'étages (habitable)", u.aireEtagesM2 ? `${u.aireEtagesM2} m² (${Math.round(u.aireEtagesM2 * 10.7639)} pi²)` : "—"],251    ["Nombre d'étages", s.nbEtages != null ? String(s.nbEtages) : "—"],252    ["Genre de construction", s.genreConstruction ?? "—"],253    ["Lien physique", s.lienPhysique ?? "—"],254    ["Nombre de logements", u.nbLogements != null ? String(u.nbLogements) : "—"],255    ["Locaux non résidentiels", s.nbLocauxNonResid != null ? String(s.nbLocauxNonResid) : "—"],256  ], y);257  doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.2 Terrain", M, y); y += 16;258  y = kvTable(c, [259    ["Superficie du terrain", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m² (${Math.round(u.superficieTerrainM2 * 10.7639)} pi²)` : "—"],260    ["Mesure frontale", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"],261    ["Unité de voisinage (rôle)", s.uniteVoisinage || "—"],262    ["Arrondissement", s.arrond || "—"],263  ], y);264  doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("3.3 Données du rôle d'évaluation", M, y); y += 16;265  y = kvTable(c, [266    ["Valeur du terrain (rôle 2026)", fmt(s.valeurTerrain)],267    ["Valeur du bâtiment (rôle 2026)", fmt(s.valeurBatiment)],268    ["Valeur totale (rôle 2026)", fmt(u.valeurRole)],269    ["Valeur au rôle antérieur", fmt(s.valeurAnterieure)],270    ["Conditions du marché considérées au", s.datCondMarche ?? "—"],271  ], y);272  y = para(c,273    "Les caractéristiques ci-dessus proviennent du rôle d'évaluation foncière en vigueur (source : MAMH, données ouvertes). " +274    "Elles n'ont pas fait l'objet d'une vérification sur place.", y);275276  /* --------------------- PAGE 4 : GRILLE DES COMPARABLES --------------------- */277  y = newPage(c, TOTAL);278  y = sectionTitle(c, "4.", "Approche par comparaison — grille d'ajustement", y);279  const comps = r.comps.slice(0, 4);280  const colW = (CW - 118) / comps.length;281  const rowsG: [string, (ci: number) => string][] = [282    ["Adresse", (i) => `${comps[i].street ?? ""}`],283    ["Municipalité", (i) => comps[i].city ?? "—"],284    ["Date de vente", (i) => comps[i].date],285    ["Prix de vente", (i) => fmt(comps[i].amount)],286    ["Distance du sujet", (i) => comps[i].distanceM < 1000 ? `${Math.round(comps[i].distanceM)} m` : `${(comps[i].distanceM / 1000).toFixed(1)} km`],287    ["Aire d'étages", (i) => comps[i].floorArea ? `${comps[i].floorArea} m²` : "—"],288    ["Année constr.", (i) => comps[i].yearBuilt ? String(comps[i].yearBuilt) : "—"],289    ["Ajust. conditions de marché", (i) => signed(comps[i].adjTime)],290    ["Ajust. superficie", (i) => signed(comps[i].adjArea)],291    ["Ajust. âge", (i) => signed(comps[i].adjAge)],292    ["PRIX AJUSTÉ", (i) => fmt(Math.round(comps[i].adjustedPrice))],293    ["Pondération", (i) => `${(comps[i].weight * 100).toFixed(0)} %`],294  ];295  const rh = 20;296  // en-têtes colonnes297  doc.rect(M, y, 118, rh).fill(INK);298  doc.font("JB-Bold").fontSize(6.4).fillColor("#ffffff").text("ÉLÉMENT", M + 6, y + 7, { lineBreak: false });299  comps.forEach((_, i) => {300    doc.rect(M + 118 + i * colW, y, colW, rh).fillAndStroke(i % 2 ? SURFACE2 : "#ffffff", INK);301    doc.font("JB-Bold").fontSize(6.6).fillColor(GREEN_DEEP)302      .text(`COMPARABLE ${i + 1}`, M + 118 + i * colW + 6, y + 7, { lineBreak: false });303  });304  y += rh;305  rowsG.forEach(([label, get]) => {306    const bold = label === "PRIX AJUSTÉ";307    doc.rect(M, y, 118, rh).fillAndStroke(bold ? LIME : SURFACE2, INK);308    doc.font("JB-Bold").fontSize(6).fillColor(INK)309      .text(label.toUpperCase(), M + 6, y + 7, { width: 108, height: 8, ellipsis: true, lineBreak: false });310    comps.forEach((_, ci) => {311      doc.rect(M + 118 + ci * colW, y, colW, rh).fillAndStroke(bold ? "#f6fbe4" : "#ffffff", INK);312      doc.font(bold ? "SG-Bold" : "Inter").fontSize(bold ? 8.4 : 7.4).fillColor(bold ? INK : INK2)313        .text(get(ci), M + 118 + ci * colW + 6, y + (bold ? 5.5 : 6.5), { width: colW - 12, height: 10, ellipsis: true, lineBreak: false });314    });315    y += rh;316  });317  y += 10;318  y = para(c,319    `${r.nCompsUsed} ventes comparables ont été retenues au total (les ${comps.length} plus pondérées sont détaillées ci-dessus ; la liste ` +320    "complète figure dans le rapport standard). Chaque prix de vente est ajusté : (i) aux conditions actuelles du marché au moyen " +321    "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 " +322    "prix unitaire au m² du comparable, plafonné à ±25 %, et (iii) pour l'écart d'âge à raison de 0,5 % par année, plafonné à ±10 %. " +323    `La médiane pondérée des prix ajustés s'établit à ${fmt(r.compsEstimate)}.`, y);324325  // mini-indice de marché326  if (marketIndex.length > 3) {327    doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("4.1 Indice des conditions de marché du segment", M, y);328    y += 16;329    const gw = CW, gh = 84;330    doc.roundedRect(M, y, gw, gh, 5).lineWidth(1).fillAndStroke("#ffffff", INK);331    const vals = marketIndex.map((p) => p.idx);332    const mn = Math.min(...vals), mx = Math.max(...vals), span = mx - mn || 1;333    const pts = marketIndex.map((p, i) => [334      M + 14 + (i / (marketIndex.length - 1)) * (gw - 28),335      y + gh - 18 - ((p.idx - mn) / span) * (gh - 36),336    ]);337    doc.moveTo(pts[0][0], pts[0][1]);338    pts.slice(1).forEach(([px, py]) => doc.lineTo(px, py));339    doc.lineWidth(1.8).stroke(GREEN);340    const last = pts[pts.length - 1];341    doc.circle(last[0], last[1], 3).lineWidth(1.2).fillAndStroke(LIME, INK);342    doc.font("JB-Reg").fontSize(6).fillColor(INK3)343      .text(marketIndex[0].month, M + 14, y + gh - 12, { lineBreak: false })344      .text(marketIndex[marketIndex.length - 1].month, M + gw - 60, y + gh - 12, { width: 46, align: "right" });345    doc.font("JB-Reg").fontSize(6).fillColor(INK3)346      .text(`BASE 1,00 = ${marketIndex[marketIndex.length - 1].month} · MÉDIANE $/M² LISSÉE 3 MOIS`, M + 14, y + 8, { characterSpacing: 0.5 });347  }348349  /* ------------------- PAGE 5 : HÉDONIQUE + RÉCONCILIATION ------------------- */350  y = newPage(c, TOTAL);351  y = sectionTitle(c, "5.", "Analyse hédonique de masse", y);352  y = para(c,353    "Un modèle de gradient boosting (LightGBM) est entraîné sur environ 690 000 transactions québécoises (2021-2026) appariées " +354    "au rôle d'évaluation foncière géoréférencé. Dix-huit variables décrivent chaque propriété (dimensions, âge, usage, " +355    "localisation fine, valeurs au rôle, temps). Sur un échantillon réservé de 102 943 ventes jamais vues du modèle, l'erreur " +356    "médiane est de 11,0 % et 72 % des estimations tombent à ±20 % du prix réel (ratio médian 0,995, sans biais systématique — " +357    "validation selon la norme IAAO sur études de ratios). L'incertitude propre à la propriété est quantifiée par deux modèles de " +358    "régression quantile (10e et 90e centiles).", y);359  y = kvTable(c, [360    ["Estimation du modèle hédonique", fmt(r.modelEstimate)],361    ["Intervalle P10-P90 du modèle", `${fmt(r.low)} → ${fmt(r.high)} (recentré)`],362    ["Erreur médiane du modèle (validation)", "11,0 % (MdAPE, holdout aléatoire)"],363  ], y);364365  y = sectionTitle(c, "6.", "Réconciliation et conclusion", y + 4);366  const rh2 = 20;367  const recRows: [string, string, string][] = [368    ["Approche", "Indication de valeur", "Pondération"],369    ["Modèle hédonique de masse", fmt(r.modelEstimate), `${(r.modelWeight * 100).toFixed(0)} %`],370    ["Comparaison directe (médiane pondérée)", fmt(r.compsEstimate), `${((1 - r.modelWeight) * 100).toFixed(0)} %`],371    ["VALEUR CONCLUE", fmt(r.estimate), "100 %"],372  ];373  recRows.forEach(([a, b, cc], i) => {374    const head = i === 0;375    const last = i === recRows.length - 1;376    doc.rect(M, y, CW, rh2).fillAndStroke(head ? INK : last ? LIME : i % 2 ? SURFACE2 : "#ffffff", INK);377    doc.font(head || last ? "JB-Bold" : "Inter").fontSize(head ? 6.8 : last ? 8.6 : 8)378      .fillColor(head ? "#ffffff" : INK)379      .text(head ? a.toUpperCase() : a, M + 8, y + 6, { lineBreak: false });380    doc.text(b, M + CW * 0.52, y + 6, { lineBreak: false });381    doc.text(cc, M + CW * 0.84, y + 6, { lineBreak: false });382    y += rh2;383  });384  y += 10;385  y = para(c,386    `Compte tenu de la qualité de l'appariement des données, du nombre et de la cohérence des comparables (dispersion ±${r.compsDispersionPct ?? "—"} %) ` +387    `et de la performance validée du modèle, la valeur marchande estimée de la propriété au ${generated} est conclue à ` +388    `${fmt(r.estimate)}, à l'intérieur d'une fourchette raisonnable de ${fmt(r.low)} à ${fmt(r.high)}. Indice de fiabilité : ` +389    `${r.confidenceLevel} (${r.confidencePct} %) — ${CONF_TXT[r.confidenceLevel]}.`, y);390391  doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text("6.1 Évolution de la valeur estimée (2021-2026)", M, y);392  y += 16;393  const hist = u.history.filter((h) => h.value != null);394  y = kvTable(c, hist.map((h) => [String(h.year), fmt(h.value)] as [string, string]), y, CW * 0.6);395396  /* ------------------- PAGE 6 : CONDITIONS + CERTIFICATION ------------------- */397  y = newPage(c, TOTAL);398  y = sectionTitle(c, "7.", "Hypothèses et conditions limitatives", y);399  const conds = [400    "Le présent rapport est généré automatiquement par le moteur d'évaluation statistique UQO Éval (modèle d'évaluation de masse, AVM), dans un cadre pédagogique universitaire. 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.",401    "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.",402    "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.",403    "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.",404    "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.",405    "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.",406    "La fourchette P10-P90 et l'indice de fiabilité font partie intégrante de la conclusion et doivent être communiqués avec elle.",407    "Ce rapport est destiné à un usage pédagogique, universitaire et expérimental (cours IMM1003 et IMM1033, UQO). Toute autre utilisation ou distribution engage la seule responsabilité de l'utilisateur.",408    "Pour toute décision réelle (financement, transaction), une évaluation par un évaluateur agréé (OEAQ) est requise.",409  ];410  conds.forEach((txt, i) => {411    doc.font("JB-Bold").fontSize(7).fillColor(GREEN).text(`7.${i + 1}`, M, y + 1, { lineBreak: false });412    doc.font("Inter").fontSize(7.9).fillColor(INK2)413      .text(txt, M + 26, y, { width: CW - 26, lineGap: 1.6, align: "justify" });414    y = doc.y + 6;415  });416417  y = sectionTitle(c, "8.", "Attestation", y + 2);418  y = para(c,419    "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 " +420    "UQO Éval pour produire la conclusion de valeur ; que la méthodologie complète, ses métriques de validation et ses limites " +421    "sont publiées à uqo-eval.app/methodologie ; et que ni UQO Éval ni son exploitant n'ont d'intérêt présent ou futur dans la " +422    "propriété évaluée, ni de lien de dépendance avec les parties à une éventuelle transaction. " + COURS, y);423  y += 14;424  doc.rect(M, y + 26, 200, 0.9).fill(INK);425  doc.image(UQO_LOGO_BLEU, M, y + 32, { height: 16 });426  doc.font("SG-Bold").fontSize(10).fillColor(INK).text("Éval", M + 16 * UQO_RATIO + 5, y + 36, { lineBreak: false });427  doc.font("Inter").fontSize(8).fillColor(INK2)428    .text("Simon-Pierre Boucher, enseignant — UQO", M, y + 52)429    .text(`Dossier ${dossier} · ${generated}`, M, y + 64);430  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)431    .text("ANNEXE DISPONIBLE : RAPPORT STANDARD (PLAN DES COMPARABLES, LISTE COMPLÈTE DES VENTES) — UQO-EVAL.APP", M, y + 86, { characterSpacing: 0.5 });432433  doc.end();434  return done;435}436