TypeScript 90.1%
JavaScript 3.2%
Python 3.2%
CSS 1.8%
HTML 1.8%
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2/**3 * Rapport PDF « Méthode du coût » (IMM1033) — vectoriel (pdfkit), même4 * branding que report.ts : bandeau encre, logo officiel UQO, cartes, filets,5 * format Lettre. Chaque nombre du rapport vient du moteur (CostEstimate) ;6 * la nature de chaque prix est marquée : ● observé/officiel · ◐ calculé ·7 * ○ indexé · ◇ référence interne (hypothèse).8 */9import PDFDocument from "pdfkit";10import path from "path";11import type { CostEstimate, PriceKind } from "./cost/types";12import { INDIRECT_LABELS, QUALITIES, BUILDING_TYPES, tradeLabel } from "./cost/taxonomy";13import { UNIT_LABELS } from "./cost/units";1415const INK = "#16181b";16const INK2 = "#4b5258";17const INK3 = "#8b928c";18const PAPER = "#f4f5f3";19const SURFACE2 = "#f9fafa";20const GREEN = "#005F85"; // bleu UQO (PMS 7469)21const GREEN_DEEP = "#00465F";22const LIME = "#2BCCAE"; // turquoise UQO23const LIME_SOFT = "#d9f4ee";24const AMBER_SOFT = "#fdf3e2";25const DANGER = "#b3423a";26const DANGER_SOFT = "#fbe9e7";27const LINE = "#dedcd4";2829const W = 612;30const H = 792;31const M = 44;32const BOTTOM = H - 62; // limite basse du contenu (au-dessus du pied de page)3334const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);35const UQO_LOGO_BLANC = path.join(process.cwd(), "assets", "uqo", "UQO_blanc.png");3637const fmt = (v: number | null | undefined) =>38 v == null || !Number.isFinite(v) ? "—" : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);39const fmt2 = (v: number | null | undefined) =>40 v == null || !Number.isFinite(v) ? "—" : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v);41const num = (v: number | null | undefined, d = 0) =>42 v == null || !Number.isFinite(v) ? "—" : v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: 0 });43const pct = (v: number | null | undefined, d = 1) => (v == null || !Number.isFinite(v) ? "—" : `${v.toLocaleString("fr-CA", { maximumFractionDigits: d })} %`);44const neg = (v: number) => (v > 0 ? `−${fmt(v)}` : fmt(0));4546const KIND_ICON: Record<PriceKind, string> = { observed: "●", official: "●", derived: "◐", indexed: "○", reference: "◇", assumption: "◇" };47const KIND_TXT: Record<PriceKind, string> = { observed: "observé", official: "officiel", derived: "calculé", indexed: "indexé", reference: "référence (hypothèse)", assumption: "hypothèse" };48const SRC_TXT: Record<string, string> = { MAMH: "rôle MAMH", user: "saisi", AI: "inféré par IA", derived: "dérivé", assumed: "supposé", listing: "annonce", cadastral: "cadastre" };49const CONF_BG: Record<string, string> = { A: LIME, B: LIME_SOFT, C: AMBER_SOFT, D: DANGER_SOFT };50const CONF_TXT: Record<string, string> = { A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable" };5152type Doc = InstanceType<typeof PDFDocument>;5354/* ------------------------------------------------------------- primitives */5556function logo(doc: Doc, x: number, y: number, scale = 1) {57 const h = 26 * scale;58 const w = h * (2123 / 853);59 doc.image(UQO_LOGO_BLANC, x, y, { height: h });60 doc.font("SG-Bold").fontSize(22 * scale).fillColor(LIME).text("Éval", x + w + 8 * scale, y + h - 22 * scale, { lineBreak: false });61}6263function kicker(doc: Doc, txt: string, x: number, y: number, color = GREEN) {64 doc.rect(x, y + 3.5, 20, 2).fill(color);65 doc.font("JB-Bold").fontSize(8).fillColor(color).text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false, width: W - 2 * M - 26, height: 12, ellipsis: true });66}6768function pageBg(doc: Doc) {69 doc.rect(0, 0, W, H).fill(PAPER);70}7172function pageFooter(doc: Doc, page: number, total: number, generated: string) {73 doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK);74 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(75 "UQO-EVAL.APP · MÉTHODE DU COÛT — ESTIMATION PÉDAGOGIQUE, NI SOUMISSION NI ÉVALUATION CERTIFIÉE (OEAQ)",76 M, H - 39, { width: W - 2 * M - 60, characterSpacing: 0.3, lineBreak: false, height: 9, ellipsis: true }77 );78 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text("COURS IMM1003 · IMM1033 — SIMON-PIERRE BOUCHER, UQO", M, H - 30, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false });79 doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" });80 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(generated, W - M - 160, H - 30, { width: 160, align: "right" });81}8283function card(doc: Doc, x: number, y: number, w: number, h: number, opts?: { shadow?: boolean; fill?: string }) {84 if (opts?.shadow !== false) doc.roundedRect(x + 3.5, y + 3.5, w, h, 8).fill("#e3e1d9");85 doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(opts?.fill ?? "#ffffff", INK);86}8788function kvCell(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string) {89 doc.lineWidth(0.8);90 doc.roundedRect(x, y, w, h, 4).fillAndStroke(SURFACE2, LINE);91 doc.font("JB-Bold").fontSize(5.8).fillColor(INK3).text(k.toUpperCase(), x + 7, y + 6, { width: w - 14, characterSpacing: 0.7, height: 8, ellipsis: true, lineBreak: false });92 doc.font("SG-Bold").fontSize(7.4).fillColor(INK).text(v, x + 7, y + 15.5, { width: w - 14, height: h - 18, ellipsis: true, lineGap: -0.5 });93 doc.lineWidth(1.3);94}9596/* -------------------------------------------------------- mise en page */9798class Layout {99 y = 34;100 constructor(readonly doc: Doc) {}101 newPage() {102 this.doc.addPage({ size: "LETTER", margin: 0 });103 pageBg(this.doc);104 this.doc.rect(0, 0, W, 8).fill(INK);105 this.doc.rect(0, 8, W, 2.5).fill(LIME);106 this.y = 34;107 }108 /** garantit `h` points disponibles, sinon nouvelle page */109 ensure(h: number) {110 if (this.y + h > BOTTOM) this.newPage();111 }112 section(title: string) {113 this.ensure(60);114 kicker(this.doc, title, M, this.y);115 this.y += 18;116 }117 para(txt: string, opts: { size?: number; color?: string; font?: string; gap?: number } = {}) {118 const d = this.doc;119 d.font(opts.font ?? "Inter").fontSize(opts.size ?? 8);120 const h = d.heightOfString(txt, { width: W - 2 * M, lineGap: 1.4 });121 this.ensure(h + 4);122 d.fillColor(opts.color ?? INK2).text(txt, M, this.y, { width: W - 2 * M, lineGap: 1.4 });123 this.y += h + (opts.gap ?? 6);124 }125 bullet(txt: string) {126 const d = this.doc;127 d.font("Inter").fontSize(7.8);128 const h = d.heightOfString(txt, { width: W - 2 * M - 14, lineGap: 1.2 });129 this.ensure(h + 3);130 d.rect(M + 2, this.y + 3.5, 4, 4).fill(GREEN);131 d.fillColor(INK2).text(txt, M + 14, this.y, { width: W - 2 * M - 14, lineGap: 1.2 });132 this.y += h + 3;133 }134 /** tableau avec en-tête répété à chaque saut de page */135 table(cols: { h: string; w: number; align?: "left" | "right"; mono?: boolean }[], rows: string[][], opts: { rowH?: number; total?: string[]; highlight?: (i: number) => boolean } = {}) {136 const d = this.doc;137 const rowH = opts.rowH ?? 14;138 const x0 = M;139 const totalW = cols.reduce((s, c) => s + c.w, 0);140 const scale = (W - 2 * M) / totalW;141 const ws = cols.map((c) => c.w * scale);142 const header = () => {143 this.ensure(rowH + 4);144 d.rect(x0, this.y, W - 2 * M, rowH).fill(INK);145 let x = x0;146 cols.forEach((c, i) => {147 d.font("JB-Bold").fontSize(6.2).fillColor(PAPER).text(c.h.toUpperCase(), x + 5, this.y + 4.2, { width: ws[i] - 10, align: c.align ?? "left", lineBreak: false, height: rowH, ellipsis: true, characterSpacing: 0.4 });148 x += ws[i];149 });150 this.y += rowH;151 };152 header();153 rows.forEach((row, ri) => {154 if (this.y + rowH > BOTTOM) { this.newPage(); header(); }155 const hl = opts.highlight?.(ri);156 if (hl) d.rect(x0, this.y, W - 2 * M, rowH).fill(LIME_SOFT);157 else if (ri % 2 === 1) d.rect(x0, this.y, W - 2 * M, rowH).fill(SURFACE2);158 let x = x0;159 cols.forEach((c, i) => {160 d.font(hl ? "SG-Bold" : c.mono ? "JB-Reg" : "Inter").fontSize(c.mono ? 6.6 : 7.2).fillColor(INK)161 .text(row[i] ?? "", x + 5, this.y + 3.6, { width: ws[i] - 10, align: c.align ?? "left", lineBreak: false, height: rowH, ellipsis: true });162 x += ws[i];163 });164 d.moveTo(x0, this.y + rowH).lineTo(W - M, this.y + rowH).lineWidth(0.4).stroke(LINE);165 this.y += rowH;166 });167 if (opts.total) {168 if (this.y + rowH + 2 > BOTTOM) { this.newPage(); header(); }169 d.rect(x0, this.y, W - 2 * M, rowH + 2).fill(INK);170 let x = x0;171 cols.forEach((c, i) => {172 d.font("SG-Bold").fontSize(7.4).fillColor(PAPER).text(opts.total![i] ?? "", x + 5, this.y + 4.2, { width: ws[i] - 10, align: c.align ?? "left", lineBreak: false, height: rowH, ellipsis: true });173 x += ws[i];174 });175 this.y += rowH + 2;176 }177 this.y += 8;178 }179 gap(h = 8) { this.y += h; }180}181182/* -------------------------------------------------------------- libellés */183184const BASEMENT: Record<string, string> = { none: "aucun (dalle)", crawl: "vide sanitaire", unfinished: "non fini", partial: "partiellement fini", finished: "fini", walkout: "fini, sortie de plain-pied" };185const FOUND: Record<string, string> = { poured_concrete: "béton coulé", concrete_block: "blocs de béton", slab_on_grade: "dalle sur sol", piers: "pieux", stone: "pierre" };186const ROOF: Record<string, string> = { asphalt_shingle: "bardeau d'asphalte", metal: "tôle", membrane: "membrane", cedar: "cèdre", slate_tile: "ardoise/tuile" };187const GEOM: Record<string, string> = { gable: "à deux versants", hip: "à quatre versants", flat: "plat", mansard: "mansardé", complex: "complexe" };188const WIN: Record<string, string> = { pvc: "PVC", hybrid: "hybride", aluminum: "aluminium", wood: "bois" };189const HEAT: Record<string, string> = { electric_baseboard: "plinthes électriques", heat_pump: "thermopompe centrale", furnace_electric: "fournaise électrique", furnace_gas: "fournaise au gaz", furnace_oil: "fournaise à l'huile", hydronic: "eau chaude", geothermal: "géothermie", wood: "bois" };190const GARAGE: Record<string, string> = { none: "aucun", attached: "attenant", detached: "détaché", integrated: "intégré", carport: "abri d'auto" };191const SIDING: Record<string, string> = { vinyl: "vinyle", brick: "brique", fiber_cement: "fibrociment", wood: "bois", stone: "pierre", stucco: "stuc", aluminum: "aluminium", steel: "acier" };192const MODE: Record<string, string> = { property: "propriété du rôle (MAMH)", construction: "construction hypothétique", listing: "annonce à vendre (analyse IA)" };193const qLabel = (q: string) => QUALITIES.find((x) => x.key === q)?.fr ?? q;194const unitLabel = (u: string) => UNIT_LABELS[u as keyof typeof UNIT_LABELS]?.fr ?? u;195const shortUrl = (u: string | null | undefined) => {196 if (!u) return "";197 try { const x = new URL(u); return (x.host.replace(/^www\./, "") + x.pathname).slice(0, 60); } catch { return u.slice(0, 60); }198};199200/* ============================================================ rapport */201202export async function buildCostReport(est: CostEstimate): Promise<Buffer> {203 const b = est.input.building;204 const generated = new Date().toISOString().slice(0, 16).replace("T", " ");205 const doc = new PDFDocument({ size: "LETTER", margin: 0, bufferPages: true, info: { Title: `UQO Éval — Méthode du coût — ${est.input.address ?? "bâtiment"}`, Author: "UQO Éval — cours IMM1003 · IMM1033 (Simon-Pierre Boucher, UQO)" } });206 doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));207 doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));208 doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));209 doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));210 doc.registerFont("Inter", F("Inter-Regular.ttf"));211 const chunks: Buffer[] = [];212 doc.on("data", (c: Buffer) => chunks.push(c));213 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));214 const L = new Layout(doc);215 const src = est.input.attributeSources;216 const prov = (k: string) => SRC_TXT[src[k] ?? ""] ?? (src[k] ?? "supposé");217218 /* ================================ PAGE 1 — couverture ================================ */219 pageBg(doc);220 doc.rect(0, 0, W, 96).fill(INK);221 logo(doc, M, 30);222 doc.font("JB-Reg").fontSize(7).fillColor(LIME).text("LABORATOIRE PÉDAGOGIQUE D'ÉVALUATION IMMOBILIÈRE · RAPPORT — MÉTHODE DU COÛT", M, 66, { characterSpacing: 1.2, lineBreak: false });223 doc.font("JB-Reg").fontSize(6).fillColor("#8fb8c9").text("IMM1033 — MÉTHODES DU COÛT EN ÉVALUATION IMMOBILIÈRE — SIMON-PIERRE BOUCHER, UQO", M, 78, { characterSpacing: 1, lineBreak: false });224 doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")225 .text(`ESTIMATION ${est.id.slice(0, 8).toUpperCase()}`, W - M - 220, 34, { width: 220, align: "right" })226 .text(`GÉNÉRÉ LE ${generated}`, W - M - 220, 46, { width: 220, align: "right" })227 .text(`PRIX AU ${est.priceDate} · MÉTHODE v${est.methodVersion}`, W - M - 220, 58, { width: 220, align: "right" });228 doc.rect(0, 96, W, 3).fill(LIME);229230 let y = 122;231 kicker(doc, "Indication de valeur par la méthode du coût", M, y);232 y += 16;233 const titre = [est.input.address, est.input.municipality].filter(Boolean).join(" · ") || `${BUILDING_TYPES.find((t) => t.key === b.type)?.fr ?? b.type} — ${num(b.grossFloorAreaSqft)} pi²`;234 doc.font("SG-Bold").fontSize(15).fillColor(INK).text(titre.toUpperCase(), M, y, { width: W - 2 * M });235 y = doc.y + 6;236 doc.font("SG-Bold").fontSize(46).fillColor(INK).text(fmt(est.costApproachValue), M, y);237 y = doc.y + 10;238239 const cols = [240 ["COÛT DE REMPLACEMENT À NEUF", fmt(est.replacementCostNew)],241 ["DÉPRÉCIATION TOTALE", neg(est.depreciation.total)],242 ["VALEUR DU TERRAIN", fmt(est.landValue)],243 ["FOURCHETTE RCN P10-P90", `${fmt(est.range.p10)} → ${fmt(est.range.p90)}`],244 ] as const;245 let cx0 = M;246 for (const [k, v] of cols) {247 doc.font("JB-Bold").fontSize(6.3).fillColor(INK3).text(k, cx0, y, { characterSpacing: 0.6, lineBreak: false });248 const lw = doc.widthOfString(k) + 6;249 doc.font("SG-Bold").fontSize(12).fillColor(INK).text(v, cx0, y + 11, { lineBreak: false });250 cx0 += Math.max(doc.widthOfString(v), lw) + 26;251 }252 const c = est.confidence;253 const confTxt = `CONFIANCE ${c.letter} · ${c.total} / 100 — ${CONF_TXT[c.letter].toUpperCase()}`;254 doc.font("JB-Bold").fontSize(7.5);255 const cw = doc.widthOfString(confTxt) + 20;256 doc.roundedRect(M, y + 34, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[c.letter], INK);257 doc.fillColor(c.letter === "D" ? DANGER : INK).text(confTxt, M + 10, y + 39.5, { lineBreak: false });258 y += 72;259260 // équation261 card(doc, M, y, W - 2 * M, 58, { fill: INK, shadow: false });262 doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("LA FORMULE — JAMAIS MASQUÉE", M + 16, y + 11, { characterSpacing: 1.4 });263 doc.font("JB-Reg").fontSize(8.2).fillColor("#e8e6df").text(264 `V_coût = V_terrain + RCN − D → ${fmt(est.costApproachValue)} = ${fmt(est.landValue)} + ${fmt(est.replacementCostNew)} − ${fmt(est.depreciation.total)}`,265 M + 16, y + 26, { width: W - 2 * M - 32, lineBreak: false, height: 12, ellipsis: true });266 doc.font("JB-Reg").fontSize(7).fillColor("#9fb4bd").text(267 `RCN = Direct ${fmt(est.directCost)} + Indirects ${fmt(est.indirectCost)} + Frais gén. ${fmt(est.contractorOverhead)} + Profit ${fmt(est.contractorProfit)} + Contingence ${fmt(est.contingency)}`,268 M + 16, y + 41, { width: W - 2 * M - 32, lineBreak: false, height: 10, ellipsis: true });269 y += 78;270271 // fiche du bâtiment (3 cartes)272 kicker(doc, "1 · Propriété et bâtiment — chaque caractéristique avec sa provenance", M, y);273 y += 16;274 const sidingTxt = Object.entries(b.siding).filter(([, v]) => (v ?? 0) > 0).map(([k, v]) => `${SIDING[k] ?? k} ${Math.round((v ?? 0) * 100)} %`).join(", ") || "—";275 const groups: [string, [string, string][]][] = [276 ["IDENTIFICATION", [277 ["Mode", MODE[est.input.mode] ?? est.input.mode],278 ["Id / annonce", est.input.propertyId ?? est.input.listingUid ?? "—"],279 ["Municipalité", est.input.municipality ?? "—"],280 ["Type", `${BUILDING_TYPES.find((t) => t.key === b.type)?.fr ?? b.type} (${prov("type")})`],281 ["Qualité", `${qLabel(b.quality)} (${prov("quality")})`],282 ["Logements", String(b.units)],283 ["Année constr.", `${b.yearBuilt ?? "—"} (${prov("yearBuilt")})`],284 ["Localisation", est.location.nameFr],285 ]],286 ["GÉOMÉTRIE", [287 ["Aire d'étages", `${num(b.grossFloorAreaSqft)} pi² (${prov("grossFloorAreaSqft")})`],288 ["Étages", `${b.stories} (${prov("stories")})`],289 ["Sous-sol", `${BASEMENT[b.basement] ?? b.basement} (${prov("basement")})`],290 ["Fondation", `${FOUND[b.foundation] ?? b.foundation} (${prov("foundation")})`],291 ["Garage", `${GARAGE[b.garage.type]}${b.garage.spaces ? ` ${b.garage.spaces} pl.` : ""} (${prov("garage")})`],292 ["Toit", `${ROOF[b.roof] ?? b.roof}, ${GEOM[b.roofGeometry] ?? b.roofGeometry} ${b.roofPitch}/12 (${prov("roof")})`],293 ["Fenêtres", `${WIN[b.windows] ?? b.windows}${b.windowCount != null ? ` × ${b.windowCount}` : ""} (${prov("windows")})`],294 ["Revêtement", `${sidingTxt} (${prov("siding")})`],295 ]],296 ["INTÉRIEUR ET MÉCANIQUE", [297 ["Chauffage", `${HEAT[b.heating] ?? b.heating} (${prov("heating")})`],298 ["Clim. / VRC", `${b.hasAirConditioning ? "clim." : "—"} / ${b.hasAirExchanger ? "VRC" : "—"}`],299 ["Cuisines", `${b.kitchens} · ${qLabel(b.kitchenQuality)} (${prov("kitchenQuality")})`],300 ["Salles de bain", `${b.bathrooms} + ${b.powderRooms} s. d'eau · ${qLabel(b.bathroomQuality)} (${prov("bathrooms")})`],301 ["Terrasse", b.deckSqft ? `${num(b.deckSqft)} pi²` : "—"],302 ["Entrée", b.drivewaySqft ? `${num(b.drivewaySqft)} pi² (${b.driveway})` : "—"],303 ["Piscine", b.pool === "none" ? "—" : b.pool === "inground" ? "creusée" : "hors terre"],304 ["Aménagement", b.landscapingSqft ? `${num(b.landscapingSqft)} pi²` : "—"],305 ]],306 ];307 const gW = (W - 2 * M - 2 * 14) / 3;308 const cellH = 38;309 const gH = 4 * (cellH + 6) + 34;310 groups.forEach(([title, rows], gi) => {311 const gx = M + gi * (gW + 14);312 card(doc, gx, y, gW, gH);313 doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP).text(title, gx + 12, y + 11, { characterSpacing: 1.2, lineBreak: false });314 doc.moveTo(gx + 12, y + 24).lineTo(gx + gW - 12, y + 24).dash(2, { space: 3 }).lineWidth(0.8).stroke(LINE).undash();315 const cw2 = (gW - 24 - 6) / 2;316 rows.forEach(([k, v], i) => {317 const col = i % 2, row = Math.floor(i / 2);318 kvCell(doc, gx + 12 + col * (cw2 + 6), y + 32 + row * (cellH + 6), cw2, cellH, k, v);319 });320 });321 y += gH + 14;322 doc.font("JB-Reg").fontSize(6.3).fillColor(INK3).text(323 "PROVENANCE : rôle MAMH = registre officiel · saisi = étudiant · inféré par IA = analyse multimodale · dérivé = formule · supposé = hypothèse par défaut. Légende des prix : ● observé/officiel · ◐ calculé · ○ indexé · ◇ référence interne (hypothèse).",324 M, y, { width: W - 2 * M, characterSpacing: 0.2, lineGap: 1 });325326 /* ================================ PAGE 2 — hypothèses & localisation ================================ */327 L.newPage();328 L.section("2 · Hypothèses — chaque pourcentage est visible, modifiable et expliqué");329 const p = est.input.params;330 L.table(331 [{ h: "Poste", w: 40 }, { h: "Taux", w: 10, align: "right", mono: true }, { h: "Base de calcul", w: 30 }, { h: "Explication (hypothèse pédagogique)", w: 60 }],332 [333 ...est.indirect.map((i) => [i.labelFr, pct(i.pct, 1), "coût direct", INDIRECT_LABELS[i.key].noteFr]),334 ["Frais généraux entrepreneur", pct(p.overheadPct, 1), "direct + indirects", "Frais fixes de l'entreprise (bureau, gestion) — distincts du profit."],335 ["Profit entrepreneur", pct(p.profitPct, 1), "direct + indirects + FG", "Rémunération du risque de l'entrepreneur général."],336 ["Contingence", pct(p.contingencyPct, 1), "direct + indirects", "Réserve pour imprévus de chantier — ni profit ni frais indirects."],337 ],338 { rowH: 15 }339 );340 L.para(`Coût de REMPLACEMENT (et non de reproduction) : le bâtiment est reconstruit avec les composantes modernes équivalentes (murs 2 × 6 R24, comble R50, poutrelles ajourées), quel que soit son année de construction. Constantes de prise de quantités : hauteur d'étage 9 pi, rapport de forme 1,5, 0,011 fenêtre de 10 pi² par pi² de plancher, 0,09 pi lin de cloison par pi², murs de sous-sol 8 pi, garage 10 pi. Dépréciation : méthode « ${est.depreciation.method === "components" ? "composante par composante (vie économique et condition de chaque groupe)" : "âge-vie simple (âge effectif ÷ vie économique)"} », vie économique ${est.depreciation.economicLife} ans, dépréciation physique plafonnée à 90 %.`);341 L.para(`Base de coûts version ${est.costDatabaseVersion} · assemblages v${est.assemblyVersion} · géométrie/quantités dérivées automatiquement sauf surcharges (${Object.keys(est.input.quantityOverrides).length}). Une estimation créée aujourd'hui peut être reconstruite plus tard avec les prix de sa date (instantané conservé).`, { size: 7.2, color: INK3 });342343 L.section("8 · Localisation — facteurs distincts matériaux / main-d'œuvre / équipement");344 const loc = est.location;345 L.table(346 [{ h: "Région", w: 40 }, { h: "Matériaux", w: 15, align: "right", mono: true }, { h: "Main-d'œuvre", w: 15, align: "right", mono: true }, { h: "Équipement", w: 15, align: "right", mono: true }, { h: "Global", w: 15, align: "right", mono: true }, { h: "Confiance", w: 15, align: "right", mono: true }],347 [[`${loc.nameFr} (${loc.code})`, loc.materialFactor.toFixed(3), loc.labourFactor.toFixed(3), loc.equipmentFactor.toFixed(3), loc.overallFactor.toFixed(3), `${loc.confidence}/100`]]348 );349 L.para(`Méthode : ${loc.sourceMethod}. Ajustement de localisation appliqué au coût direct : ${fmt(est.lines.reduce((s, l) => s + l.locationAdjustment, 0))} (matériaux × ${loc.materialFactor}, main-d'œuvre × ${loc.labourFactor}, équipement × ${loc.equipmentFactor}). Les conventions collectives de la construction (Loi R-20) fixent des taux uniformes pour tout le Québec : le facteur main-d'œuvre est 1,00 sauf régions éloignées.`);350351 /* ================================ 4 · direct par catégorie ================================ */352 L.section("4 · Coût direct par catégorie — matériaux, main-d'œuvre, équipement");353 const maxAdj = Math.max(1, ...est.categories.map((k) => k.adjusted));354 L.table(355 [{ h: "Catégorie", w: 34 }, { h: "Matériaux", w: 16, align: "right", mono: true }, { h: "Main-d'œuvre", w: 16, align: "right", mono: true }, { h: "Équip.", w: 12, align: "right", mono: true }, { h: "Direct localisé", w: 16, align: "right", mono: true }, { h: "Part", w: 10, align: "right", mono: true }],356 est.categories.map((k) => [k.labelFr, fmt(k.material), fmt(k.labour), fmt(k.equipment), fmt(k.adjusted), pct(k.sharePct, 1)]),357 { total: ["COÛTS DIRECTS", fmt(est.directMaterial), fmt(est.directLabour), fmt(est.directEquipment), fmt(est.directCost), "100 %"] }358 );359 // barres360 L.ensure(est.categories.length * 11 + 14);361 est.categories.forEach((k) => {362 const trackX = M + 150, trackW = W - 2 * M - 150 - 70;363 doc.font("JB-Reg").fontSize(6.4).fillColor(INK).text(k.labelFr, M, L.y + 1, { width: 145, lineBreak: false, height: 9, ellipsis: true });364 doc.rect(trackX, L.y, trackW, 8).fill(SURFACE2);365 const wM = (k.material / maxAdj) * trackW, wL = (k.labour / maxAdj) * trackW, wE = (k.equipment / maxAdj) * trackW;366 doc.rect(trackX, L.y, wM, 8).fill(INK);367 doc.rect(trackX + wM, L.y, wL, 8).fill(GREEN);368 doc.rect(trackX + wM + wL, L.y, wE, 8).fill(LIME);369 doc.font("JB-Bold").fontSize(6.4).fillColor(INK).text(fmt(k.adjusted), W - M - 66, L.y + 1, { width: 66, align: "right", lineBreak: false });370 L.y += 11;371 });372 doc.font("JB-Reg").fontSize(6.2).fillColor(INK3).text("■ MATÉRIAUX (encre) · ■ MAIN-D'ŒUVRE (bleu UQO) · ■ ÉQUIPEMENT (turquoise)", M, L.y + 2, { characterSpacing: 0.3 });373 L.y += 16;374375 /* ================================ 5 · indirects + 9 · RCN ================================ */376 L.section("5 · Indirects, frais généraux, profit, contingence → 9 · RCN");377 L.table(378 [{ h: "Poste", w: 55 }, { h: "Taux", w: 15, align: "right", mono: true }, { h: "Montant", w: 30, align: "right", mono: true }],379 [380 ["Coûts directs localisés", "", fmt(est.directCost)],381 ...est.indirect.map((i) => [`Indirect — ${i.labelFr}`, pct(i.pct, 1), fmt(i.amount)]),382 ["Sous-total coûts indirects", pct(est.indirect.reduce((s, i) => s + i.pct, 0), 1), fmt(est.indirectCost)],383 ["Frais généraux entrepreneur", pct(p.overheadPct, 1), fmt(est.contractorOverhead)],384 ["Profit entrepreneur", pct(p.profitPct, 1), fmt(est.contractorProfit)],385 ["Contingence", pct(p.contingencyPct, 1), fmt(est.contingency)],386 ],387 { total: ["COÛT DE REMPLACEMENT À NEUF (RCN)", "", fmt(est.replacementCostNew)], highlight: (i) => i === 0 || i === est.indirect.length + 1 }388 );389 L.ensure(70);390 const tW3 = (W - 2 * M - 28) / 3;391 [["RCN TOTAL", fmt(est.replacementCostNew)], ["RCN PAR PI² D'AIRE D'ÉTAGES", `${fmt2(est.perSqft)} / pi²`], ["RCN PAR M²", `${fmt2(est.perM2)} / m²`]].forEach(([k, v], i) => {392 const x = M + i * (tW3 + 14);393 card(doc, x, L.y, tW3, 52);394 doc.font("JB-Bold").fontSize(6.3).fillColor(INK3).text(k, x + 12, L.y + 10, { characterSpacing: 0.6, lineBreak: false, width: tW3 - 24, height: 9, ellipsis: true });395 doc.font("SG-Bold").fontSize(17).fillColor(INK).text(v, x + 12, L.y + 24, { lineBreak: false, width: tW3 - 24, height: 22, ellipsis: true });396 });397 L.y += 74;398399 /* ================================ 13 · fourchette ================================ */400 L.section("13 · Fourchette de coût — P10 · central · P90");401 L.ensure(60);402 {403 const r = est.range;404 const lo = r.p10 * 0.96, hi = r.p90 * 1.04;405 const tx = M, tw = W - 2 * M;406 const X = (v: number) => tx + ((v - lo) / (hi - lo)) * tw;407 doc.rect(tx, L.y + 14, tw, 10).fill(SURFACE2);408 doc.rect(X(r.p10), L.y + 14, X(r.p90) - X(r.p10), 10).fill(LIME_SOFT);409 doc.rect(X(r.p10), L.y + 10, 1.5, 18).fill(GREEN);410 doc.rect(X(r.p90), L.y + 10, 1.5, 18).fill(GREEN);411 doc.rect(X(est.replacementCostNew) - 1.5, L.y + 6, 3, 26).fill(INK);412 doc.font("JB-Bold").fontSize(6.5).fillColor(GREEN_DEEP).text(`P10 ${fmt(r.p10)}`, X(r.p10) - 40, L.y + 32, { width: 80, align: "center", lineBreak: false });413 doc.text(`P90 ${fmt(r.p90)}`, X(r.p90) - 40, L.y + 32, { width: 80, align: "center", lineBreak: false });414 doc.fillColor(INK).text(`RCN ${fmt(est.replacementCostNew)}`, X(est.replacementCostNew) - 50, L.y - 2, { width: 100, align: "center", lineBreak: false });415 L.y += 46;416 }417 L.para(`Écart-type combiné ${fmt(est.range.sigma)} : dispersion des prix de chaque article (observés multi-sources, ou ±8 % observé / ±15 % référence / ±20 % hypothèse), incertitude des heures (±15 %), de l'équipement (±20 %) et des quantités (dérivées ±10 %, saisies ±3 %, IA ±15 %), combinées en racine des carrés, plus une composante systématique (localisation, modèle d'assemblages 5 %). P10/P90 = RCN ∓ 1,28 σ.`);418419 /* ================================ 10 · dépréciation ================================ */420 L.newPage();421 L.section("10 · Dépréciation — physique, fonctionnelle, externe");422 const d = est.depreciation;423 L.table(424 [{ h: "Élément", w: 60 }, { h: "Valeur", w: 40, align: "right", mono: true }],425 [426 ["Âge chronologique", d.chronologicalAge != null ? `${d.chronologicalAge} ans` : "—"],427 ["Âge effectif retenu", d.effectiveAge != null ? `${num(d.effectiveAge, 1)} ans (${SRC_TXT[est.input.depreciation.effectiveAgeSource] ?? est.input.depreciation.effectiveAgeSource})` : "—"],428 ["Vie économique", `${d.economicLife} ans`],429 ["Méthode", d.method === "components" ? "composante par composante" : "âge-vie simple"],430 ["Détérioration physique", `${pct(d.physicalPct, 1)} → ${fmt(d.physical)}`],431 ]432 );433 if (d.method === "components" && d.components.length) {434 L.table(435 [{ h: "Composante", w: 28 }, { h: "RCN", w: 13, align: "right", mono: true }, { h: "Vie", w: 8, align: "right", mono: true }, { h: "Condition", w: 11 }, { h: "Âge eff.", w: 11, align: "right", mono: true }, { h: "Dépr. %", w: 10, align: "right", mono: true }, { h: "Dépréciation", w: 14, align: "right", mono: true }, { h: "Restant", w: 14, align: "right", mono: true }],436 d.components.map((k) => [k.labelFr, fmt(k.rcn), `${k.economicLife} a`, k.condition ?? "— (âge)", num(k.effectiveAge, 1), pct(k.depreciationPct, 0), fmt(k.depreciation), fmt(k.remaining)]),437 { total: ["TOTAL PHYSIQUE", fmt(d.components.reduce((s, k) => s + k.rcn, 0)), "", "", "", pct(d.physicalPct, 1), fmt(d.physical), fmt(d.components.reduce((s, k) => s + k.remaining, 0))] }438 );439 L.para("Règle : âge effectif de la composante = ratio de condition × vie économique (règles pédagogiques modifiables : neuf 0 · excellent 0,10 · rénové 0,12 · très bon 0,20 · bon 0,35 · moyen 0,55 · sous la moyenne 0,70 · mauvais 0,85) ; sans condition saisie, l'âge chronologique plafonné à la vie est utilisé.", { size: 7.2, color: INK3 });440 }441 const fo = est.input.depreciation.functional;442 L.table(443 [{ h: "Désuétude", w: 34 }, { h: "Type", w: 16 }, { h: "Détail", w: 30 }, { h: "Montant", w: 20, align: "right", mono: true }],444 [445 ...fo.map((f) => [`Fonctionnelle — ${f.type || "—"}`, f.curable ? "curable" : "incurable", f.curable ? `coût de correction ${fmt(f.costToCure)}` : `perte de valeur ${fmt(f.valueLoss)}`, neg(f.curable ? f.costToCure : f.valueLoss)]),446 ["Désuétude fonctionnelle (total)", "", `${fo.length} déficience(s)`, neg(d.functional)],447 ["Désuétude externe", "", est.input.depreciation.externalNote || "aucune preuve saisie → 0 $", neg(d.external)],448 ],449 { total: ["DÉPRÉCIATION TOTALE", "", `${pct(est.replacementCostNew ? (d.total / est.replacementCostNew) * 100 : 0, 1)} du RCN`, neg(d.total)] }450 );451452 /* ================================ 11 · terrain + 12 · valeur ================================ */453 L.section("11 · Valeur du terrain");454 const land = est.input.land;455 const LANDSRC: Record<string, string> = { role: `Rôle d'évaluation foncière ${land.rollYear ?? 2026} (MAMH)`, user: "Valeur saisie par l'étudiant", market: "Valeur marchande (ventes de terrains)", residual: "Technique du résiduel", none: "Aucune valeur — 0 $ (à compléter)" };456 L.table([{ h: "Valeur du terrain utilisée", w: 30, align: "right", mono: true }, { h: "Source", w: 35 }, { h: "Méthode", w: 35 }], [[fmt(est.landValue), LANDSRC[land.source] ?? land.source, land.method || "—"]]);457 L.para("⚠ La valeur du terrain au rôle n'est pas nécessairement la valeur marchande actuelle du terrain : le rôle est établi 18 à 24 mois avant son entrée en vigueur. L'étudiant peut la conserver, la remplacer ou la documenter par une autre méthode.", { color: DANGER, size: 7.4 });458459 L.section("12 · Indication de valeur par la méthode du coût");460 L.table(461 [{ h: "Poste", w: 60 }, { h: "Montant", w: 40, align: "right", mono: true }],462 [463 ...est.categories.map((k) => [k.labelFr, fmt(k.adjusted)]),464 ["Coûts directs", fmt(est.directCost)],465 ["Coûts indirects", fmt(est.indirectCost)],466 ["Frais généraux", fmt(est.contractorOverhead)],467 ["Profit", fmt(est.contractorProfit)],468 ["Contingence", fmt(est.contingency)],469 ["COÛT DE REMPLACEMENT À NEUF", fmt(est.replacementCostNew)],470 ["Détérioration physique", neg(d.physical)],471 ["Désuétude fonctionnelle", neg(d.functional)],472 ["Désuétude externe", neg(d.external)],473 ["Valeur dépréciée du bâtiment", fmt(d.depreciatedImprovementValue)],474 ["Valeur du terrain", fmt(est.landValue)],475 ],476 { total: ["INDICATION PAR LE COÛT", fmt(est.costApproachValue)], highlight: (i) => [est.categories.length, est.categories.length + 5, est.categories.length + 9].includes(i) }477 );478479 /* ================================ 14 · confiance + benchmarks ================================ */480 L.section("14 · Indice de confiance — décomposition");481 L.table(482 [{ h: "Critère", w: 50 }, { h: "Score", w: 20, align: "right", mono: true }, { h: "Maximum", w: 30, align: "right", mono: true }],483 [["Fraîcheur des prix", String(c.freshness), "20"], ["Couverture (prix observés vs référence)", String(c.coverage), "20"], ["Localisation", String(c.location), "15"], ["Main-d'œuvre (grilles officielles)", String(c.labour), "15"], ["Benchmarks externes", String(c.benchmarks), "10"], ["Complétude du bâtiment", String(c.building), "20"]],484 { total: [`TOTAL — ${c.letter} · ${CONF_TXT[c.letter]}`, String(c.total), "100"] }485 );486 c.notesFr.forEach((n) => L.bullet(n));487 L.bullet(`Couverture des prix : ${pct(est.coverage.materialObservedShare * 100, 0)} du coût des matériaux repose sur des prix observés/officiels ; ${est.coverage.assembliesPriced}/${est.coverage.assembliesTotal} assemblages chiffrés.`);488 L.gap(4);489 L.section("Validation externe — benchmarks");490 if (est.benchmarks.length) {491 L.table(492 [{ h: "Source", w: 26 }, { h: "Type / marché", w: 26 }, { h: "Plage", w: 20, align: "right", mono: true }, { h: "UQO Éval", w: 14, align: "right", mono: true }, { h: "État", w: 14 }],493 est.benchmarks.map((k) => [`${k.source} ${k.year}`, `${k.buildingType} · ${k.market}`, `${fmt(k.low)}–${fmt(k.high)} ${k.unit}`, fmt2(k.estimatePerUnit), k.status === "within" ? "dans la plage" : k.status === "below" ? `⚠ sous la plage (${pct(k.deviationPct, 0)})` : k.status === "above" ? `⚠ au-dessus (${pct(k.deviationPct, 0)})` : "non disponible"])494 );495 L.para("Le benchmark ne modifie jamais l'estimation : un écart important est signalé, pas corrigé.", { size: 7.2, color: INK3 });496 } else {497 L.para("Donnée non disponible — aucun benchmark externe importé pour ce type de bâtiment et ce marché (le guide Altus requiert un import manuel après téléchargement légal).", { color: INK3 });498 }499 if (est.otherReadings) {500 const o = est.otherReadings;501 L.section("Trois lectures de la valeur — à comparer, jamais à moyenner");502 L.table(503 [{ h: "Lecture", w: 50 }, { h: "Valeur", w: 25, align: "right", mono: true }, { h: "vs coût", w: 25, align: "right", mono: true }],504 [505 ["Modèle hédonique (LightGBM)", fmt(o.hedonic), o.hedonic ? pct(((o.hedonic - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],506 ["Comparables ajustés", fmt(o.comparables), o.comparables ? pct(((o.comparables - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],507 ["Mesure UQO Éval (hybride 65/35)", fmt(o.hybrid), o.hybrid ? pct(((o.hybrid - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],508 ["Méthode du coût (ce rapport)", fmt(est.costApproachValue), "—"],509 ...(o.askingPrice ? [["Prix demandé", fmt(o.askingPrice), pct(((o.askingPrice - est.costApproachValue) / est.costApproachValue) * 100, 1)]] : []),510 ["Rôle 2026 — valeur totale", fmt(o.rollValue), o.rollValue ? pct(((o.rollValue - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],511 ["Rôle 2026 — bâtiment / terrain", `${fmt(o.rollBuilding)} / ${fmt(o.rollLand)}`, ""],512 ]513 );514 L.para(`RCN vs valeur du bâtiment au rôle : ${fmt(est.replacementCostNew)} vs ${fmt(o.rollBuilding)} ; valeur dépréciée vs rôle bâtiment : ${fmt(d.depreciatedImprovementValue)} vs ${fmt(o.rollBuilding)}. Le but pédagogique est de comparer les approches, pas de faire une moyenne naïve.`, { size: 7.2, color: INK3 });515 }516517 /* ================================ 3 + 6 · assemblages ================================ */518 L.newPage();519 L.section("3 · Assemblages retenus (6 · quantités, coûts unitaires, totaux)");520 L.para(`${est.lines.length} assemblages quantifiés par le moteur de géométrie (formule affichée) ou surchargés (${Object.keys(est.input.quantityOverrides).length}). Coût unitaire = matériaux (qté × pertes × prix) + main-d'œuvre (heures × taux employeur) + équipement, avant facteur régional. Prov. = nature dominante des prix des composants.`, { size: 7.2, color: INK3 });521 const kindOf = (l: CostEstimate["lines"][number]): string => {522 const kinds = l.unitDetail.components.filter((k) => k.itemCode).map((k) => k.provenance.kind);523 if (!kinds.length) return "◐";524 if (kinds.every((k) => k === "observed" || k === "official")) return "●";525 if (kinds.some((k) => k === "observed" || k === "official")) return "◐";526 if (kinds.some((k) => k === "indexed")) return "○";527 return "◇";528 };529 L.table(530 [{ h: "Assemblage", w: 30 }, { h: "Quantité", w: 11, align: "right", mono: true }, { h: "Formule", w: 27 }, { h: "Unitaire", w: 10, align: "right", mono: true }, { h: "M / MO / É", w: 14, align: "right", mono: true }, { h: "Total loc.", w: 11, align: "right", mono: true }, { h: "P", w: 3 }],531 est.lines.map((l) => [l.nameFr, `${num(l.quantity, l.quantity < 10 ? 1 : 0)} ${unitLabel(l.unit)}`, l.quantityFormula.replace(/\s+/g, " "), fmt2(l.unitCost), `${num(l.material / 1000, 1)}k/${num(l.labour / 1000, 1)}k/${num(l.equipment / 1000, 1)}k`, fmt(l.adjusted), kindOf(l)]),532 { rowH: 13, total: ["COÛTS DIRECTS LOCALISÉS", "", "", "", `${num(est.directMaterial / 1000, 0)}k/${num(est.directLabour / 1000, 0)}k/${num(est.directEquipment / 1000, 0)}k`, fmt(est.directCost), ""] }533 );534 // descriptions des assemblages535 L.section("Description des assemblages");536 const seen = new Set<string>();537 for (const l of est.lines) {538 if (seen.has(l.assemblyCode)) continue;539 seen.add(l.assemblyCode);540 const a = est.lines.find((x) => x.assemblyCode === l.assemblyCode)!;541 const desc = a.unitDetail.components.filter((k) => k.itemCode).map((k) => `${k.nameFr} ${num(k.quantity, 3)} ${unitLabel(k.unit)}`).join(" · ");542 L.para(`${l.nameFr} [${l.assemblyCode}, par ${unitLabel(l.unit)}] — ${desc || "main-d'œuvre/équipement seulement"}.`, { size: 6.8, color: INK2, gap: 3 });543 }544545 /* ================================ 7 · main-d'œuvre ================================ */546 L.newPage();547 L.section("7 · Main-d'œuvre — heures et coût par métier (taux employeur complet)");548 const trades = new Map<string, { h: number; cost: number; rate: number | null; src: string; date: string; kind: string }>();549 for (const l of est.lines) for (const k of l.unitDetail.components) {550 if (!k.labourHours) continue;551 const t = k.trade ?? "—";552 const cur = trades.get(t) ?? { h: 0, cost: 0, rate: k.hourlyRate, src: k.rateProvenance?.source ?? "—", date: k.rateProvenance?.effectiveDate ?? "—", kind: k.rateProvenance?.kind === "official" ? "● officiel" : "◇ repli (hypothèse)" };553 cur.h += k.labourHours * l.quantity;554 cur.cost += k.labourCost * l.quantity * loc.labourFactor;555 trades.set(t, cur);556 }557 const trows = [...trades.entries()].sort((a, b) => b[1].cost - a[1].cost);558 L.table(559 [{ h: "Métier", w: 22 }, { h: "Heures", w: 9, align: "right", mono: true }, { h: "Taux employeur", w: 13, align: "right", mono: true }, { h: "Coût localisé", w: 13, align: "right", mono: true }, { h: "Source", w: 33 }, { h: "En vigueur", w: 10, mono: true }],560 trows.map(([t, v]) => [tradeLabel(t, true), num(v.h, 0), v.rate != null ? `${fmt2(v.rate)}/h` : "—", fmt(v.cost), `${v.kind} — ${v.src}`, v.date]),561 { total: ["TOTAL MAIN-D'ŒUVRE", num(trows.reduce((s, [, v]) => s + v.h, 0), 0), "", fmt(est.directLabour), "", ""] }562 );563 L.para("Le taux employeur complet inclut le salaire horaire conventionné (CCQ), l'indemnité de congés (13 %), les avantages sociaux, les cotisations (AE, RQAP, RRQ, FSS, CNESST, prélèvement CCQ, AECQ, fonds de formation) — jamais le salaire horaire nu. Les heures par unité d'assemblage sont des hypothèses de productivité résidentielle documentées.", { size: 7.2, color: INK3 });564565 /* ================================ 15 · sources ================================ */566 L.section("15 · Sources — chaque prix important est retraçable");567 const srcMap = new Map<string, { kind: string; date: string; url: string; n: number }>();568 for (const l of est.lines) for (const k of l.unitDetail.components) {569 if (!k.itemCode) continue;570 for (const nm of k.provenance.source.split(", ")) {571 const key = nm.trim();572 const cur = srcMap.get(key) ?? { kind: `${KIND_ICON[k.provenance.kind]} ${KIND_TXT[k.provenance.kind]}`, date: k.provenance.observedAt ?? k.provenance.effectiveDate ?? "—", url: shortUrl(k.provenance.sourceUrl), n: 0 };573 cur.n++;574 srcMap.set(key, cur);575 }576 if (k.rateProvenance) {577 const key = k.rateProvenance.source;578 const cur = srcMap.get(key) ?? { kind: `${KIND_ICON[k.rateProvenance.kind]} ${KIND_TXT[k.rateProvenance.kind]}`, date: k.rateProvenance.effectiveDate ?? "—", url: shortUrl(k.rateProvenance.sourceUrl), n: 0 };579 cur.n++;580 srcMap.set(key, cur);581 }582 }583 L.table(584 [{ h: "Source", w: 34 }, { h: "Nature", w: 20 }, { h: "Date", w: 12, mono: true }, { h: "Composants", w: 10, align: "right", mono: true }, { h: "URL", w: 24, mono: true }],585 [...srcMap.entries()].sort((a, b) => b[1].n - a[1].n).map(([k, v]) => [k, v.kind, v.date, String(v.n), v.url]),586 { rowH: 13 }587 );588 L.para(`Localisation : ${loc.nameFr} — ${loc.sourceMethod}. Indices : Statistique Canada 18-10-0289-01 (actualisation des observations anciennes). Prix de référence internes (◇) : hypothèses documentées pour les articles sans observation détaillant — ils réduisent la couverture et l'indice de confiance.`, { size: 7.2, color: INK3 });589590 /* ================================ 16 · limites ================================ */591 L.section("16 · Limites");592 [593 "Laboratoire pédagogique : les coûts présentés sont des estimations et ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ).",594 "Les quantités sont dérivées de formules géométriques génériques (rapport de forme, hauteurs, ratios d'ouvertures) et non d'un relevé des plans ; les surcharges saisies priment.",595 "Les caractéristiques « supposées » (revêtement, toiture, chauffage, qualité…) sont des valeurs par défaut à confirmer ; leur part abaisse le score « complétude du bâtiment ».",596 "Les prix de référence internes (◇) ne sont pas observés ; les prix détaillants observés (●) reflètent le prix affiché au détail, non les escomptes d'entrepreneur.",597 "La dépréciation dépend fortement de l'âge effectif et des conditions saisies ; la désuétude externe n'est jamais calculée sans preuve.",598 "Le facteur de localisation matériaux/équipement des régions éloignées est une hypothèse ; la main-d'œuvre suit les conventions collectives provinciales (CCQ).",599 ...(est.warnings.filter((w) => !w.startsWith("localisation")).map((w) => `Avertissement du moteur : ${w}.`)),600 ].forEach((t) => L.bullet(t));601602 /* ---- pieds de page avec total ---- */603 const range = doc.bufferedPageRange();604 for (let i = range.start; i < range.start + range.count; i++) {605 doc.switchToPage(i);606 pageFooter(doc, i + 1, range.count, generated);607 }608 doc.end();609 return done;610}611