Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Rapport PDF « Méthode du coût » — vectoriel (pdfkit), même4 * branding que report.ts : bandeau encre, logo Vrai-Prix, 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 = "#141814";16const INK2 = "#4d5551";17const INK3 = "#8b928c";18const PAPER = "#f5f3ee";19const SURFACE2 = "#faf9f5";20const GREEN = "#9e2a25"; // bordeaux Vrai-Prix (--green)21const GREEN_DEEP = "#771f1b";22const LIME = "#ff5148"; // rouge vif Vrai-Prix (--accent-bright)23const LIME_SOFT = "#ffe3e0";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);3536const fmt = (v: number | null | undefined) =>37 v == null || !Number.isFinite(v) ? "—" : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v);38const fmt2 = (v: number | null | undefined) =>39 v == null || !Number.isFinite(v) ? "—" : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v);40const num = (v: number | null | undefined, d = 0) =>41 v == null || !Number.isFinite(v) ? "—" : v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: 0 });42const pct = (v: number | null | undefined, d = 1) => (v == null || !Number.isFinite(v) ? "—" : `${v.toLocaleString("fr-CA", { maximumFractionDigits: d })} %`);43const neg = (v: number) => (v > 0 ? `−${fmt(v)}` : fmt(0));4445const KIND_ICON: Record<PriceKind, string> = { observed: "●", official: "●", derived: "◐", indexed: "○", reference: "◇", assumption: "◇" };46const KIND_TXT: Record<PriceKind, string> = { observed: "observé", official: "officiel", derived: "calculé", indexed: "indexé", reference: "référence (hypothèse)", assumption: "hypothèse" };47const 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" };48const CONF_BG: Record<string, string> = { A: LIME, B: LIME_SOFT, C: AMBER_SOFT, D: DANGER_SOFT };49const CONF_TXT: Record<string, string> = { A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable" };5051type Doc = InstanceType<typeof PDFDocument>;5253/* ------------------------------------------------------------- primitives */5455/** Mot-symbole Vrai-Prix (identique à report.ts) : « Vrai » papier + « Prix » sur pastille rouge inclinée. */56function logo(doc: Doc, x: number, y: number, scale = 1) {57 doc.save();58 doc.font("SG-Bold").fontSize(24 * scale);59 doc.fillColor(PAPER).text("Vrai", x, y, { lineBreak: false });60 const w = doc.widthOfString("Vrai");61 const bx = x + w + 4 * scale;62 doc.save();63 doc.rotate(-3, { origin: [bx, y + 12 * scale] });64 const bw = doc.widthOfString("Prix") + 12 * scale;65 doc.roundedRect(bx, y - 3 * scale, bw, 30 * scale, 5 * scale).fill(LIME);66 doc.fillColor(INK).text("Prix", bx + 6 * scale, y, { lineBreak: false });67 doc.restore();68 doc.restore();69}7071function kicker(doc: Doc, txt: string, x: number, y: number, color = GREEN) {72 doc.rect(x, y + 3.5, 20, 2).fill(color);73 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 });74}7576function pageBg(doc: Doc) {77 doc.rect(0, 0, W, H).fill(PAPER);78}7980function pageFooter(doc: Doc, page: number, total: number, generated: string) {81 doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK);82 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(83 "VRAI-PRIX.COM · MÉTHODE DU COÛT — ESTIMATION STATISTIQUE À TITRE INDICATIF, NI SOUMISSION NI ÉVALUATION CERTIFIÉE (OEAQ)",84 M, H - 39, { width: W - 2 * M - 60, characterSpacing: 0.3, lineBreak: false, height: 9, ellipsis: true }85 );86 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text("SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI", M, H - 30, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false });87 doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" });88 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(generated, W - M - 160, H - 30, { width: 160, align: "right" });89}9091function card(doc: Doc, x: number, y: number, w: number, h: number, opts?: { shadow?: boolean; fill?: string }) {92 if (opts?.shadow !== false) doc.roundedRect(x + 3.5, y + 3.5, w, h, 8).fill("#e3e1d9");93 doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(opts?.fill ?? "#ffffff", INK);94}9596function kvCell(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string) {97 doc.lineWidth(0.8);98 doc.roundedRect(x, y, w, h, 4).fillAndStroke(SURFACE2, LINE);99 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 });100 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 });101 doc.lineWidth(1.3);102}103104/* -------------------------------------------------------- mise en page */105106class Layout {107 y = 34;108 constructor(readonly doc: Doc) {}109 newPage() {110 this.doc.addPage({ size: "LETTER", margin: 0 });111 pageBg(this.doc);112 this.doc.rect(0, 0, W, 8).fill(INK);113 this.doc.rect(0, 8, W, 2.5).fill(LIME);114 this.y = 34;115 }116 /** garantit `h` points disponibles, sinon nouvelle page */117 ensure(h: number) {118 if (this.y + h > BOTTOM) this.newPage();119 }120 section(title: string) {121 this.ensure(60);122 kicker(this.doc, title, M, this.y);123 this.y += 18;124 }125 para(txt: string, opts: { size?: number; color?: string; font?: string; gap?: number } = {}) {126 const d = this.doc;127 d.font(opts.font ?? "Inter").fontSize(opts.size ?? 8);128 const h = d.heightOfString(txt, { width: W - 2 * M, lineGap: 1.4 });129 this.ensure(h + 4);130 d.fillColor(opts.color ?? INK2).text(txt, M, this.y, { width: W - 2 * M, lineGap: 1.4 });131 this.y += h + (opts.gap ?? 6);132 }133 bullet(txt: string) {134 const d = this.doc;135 d.font("Inter").fontSize(7.8);136 const h = d.heightOfString(txt, { width: W - 2 * M - 14, lineGap: 1.2 });137 this.ensure(h + 3);138 d.rect(M + 2, this.y + 3.5, 4, 4).fill(GREEN);139 d.fillColor(INK2).text(txt, M + 14, this.y, { width: W - 2 * M - 14, lineGap: 1.2 });140 this.y += h + 3;141 }142 /** tableau avec en-tête répété à chaque saut de page */143 table(cols: { h: string; w: number; align?: "left" | "right"; mono?: boolean }[], rows: string[][], opts: { rowH?: number; total?: string[]; highlight?: (i: number) => boolean } = {}) {144 const d = this.doc;145 const rowH = opts.rowH ?? 14;146 const x0 = M;147 const totalW = cols.reduce((s, c) => s + c.w, 0);148 const scale = (W - 2 * M) / totalW;149 const ws = cols.map((c) => c.w * scale);150 const header = () => {151 this.ensure(rowH + 4);152 d.rect(x0, this.y, W - 2 * M, rowH).fill(INK);153 let x = x0;154 cols.forEach((c, i) => {155 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 });156 x += ws[i];157 });158 this.y += rowH;159 };160 header();161 rows.forEach((row, ri) => {162 if (this.y + rowH > BOTTOM) { this.newPage(); header(); }163 const hl = opts.highlight?.(ri);164 if (hl) d.rect(x0, this.y, W - 2 * M, rowH).fill(LIME_SOFT);165 else if (ri % 2 === 1) d.rect(x0, this.y, W - 2 * M, rowH).fill(SURFACE2);166 let x = x0;167 cols.forEach((c, i) => {168 d.font(hl ? "SG-Bold" : c.mono ? "JB-Reg" : "Inter").fontSize(c.mono ? 6.6 : 7.2).fillColor(INK)169 .text(row[i] ?? "", x + 5, this.y + 3.6, { width: ws[i] - 10, align: c.align ?? "left", lineBreak: false, height: rowH, ellipsis: true });170 x += ws[i];171 });172 d.moveTo(x0, this.y + rowH).lineTo(W - M, this.y + rowH).lineWidth(0.4).stroke(LINE);173 this.y += rowH;174 });175 if (opts.total) {176 if (this.y + rowH + 2 > BOTTOM) { this.newPage(); header(); }177 d.rect(x0, this.y, W - 2 * M, rowH + 2).fill(INK);178 let x = x0;179 cols.forEach((c, i) => {180 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 });181 x += ws[i];182 });183 this.y += rowH + 2;184 }185 this.y += 8;186 }187 gap(h = 8) { this.y += h; }188}189190/* -------------------------------------------------------------- libellés */191192const BASEMENT: Record<string, string> = { none: "aucun (dalle)", crawl: "vide sanitaire", unfinished: "non fini", partial: "partiellement fini", finished: "fini", walkout: "fini, sortie de plain-pied" };193const 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" };194const ROOF: Record<string, string> = { asphalt_shingle: "bardeau d'asphalte", metal: "tôle", membrane: "membrane", cedar: "cèdre", slate_tile: "ardoise/tuile" };195const GEOM: Record<string, string> = { gable: "à deux versants", hip: "à quatre versants", flat: "plat", mansard: "mansardé", complex: "complexe" };196const WIN: Record<string, string> = { pvc: "PVC", hybrid: "hybride", aluminum: "aluminium", wood: "bois" };197const 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" };198const GARAGE: Record<string, string> = { none: "aucun", attached: "attenant", detached: "détaché", integrated: "intégré", carport: "abri d'auto" };199const SIDING: Record<string, string> = { vinyl: "vinyle", brick: "brique", fiber_cement: "fibrociment", wood: "bois", stone: "pierre", stucco: "stuc", aluminum: "aluminium", steel: "acier" };200const MODE: Record<string, string> = { property: "propriété du rôle (MAMH)", construction: "construction hypothétique", listing: "annonce à vendre (analyse IA)" };201const qLabel = (q: string) => QUALITIES.find((x) => x.key === q)?.fr ?? q;202const unitLabel = (u: string) => UNIT_LABELS[u as keyof typeof UNIT_LABELS]?.fr ?? u;203const shortUrl = (u: string | null | undefined) => {204 if (!u) return "";205 try { const x = new URL(u); return (x.host.replace(/^www\./, "") + x.pathname).slice(0, 60); } catch { return u.slice(0, 60); }206};207208/* ============================================================ rapport */209210export async function buildCostReport(est: CostEstimate): Promise<Buffer> {211 const b = est.input.building;212 const generated = new Date().toISOString().slice(0, 16).replace("T", " ");213 const doc = new PDFDocument({ size: "LETTER", margin: 0, bufferPages: true, info: { Title: `Vrai-Prix — Méthode du coût — ${est.input.address ?? "bâtiment"}`, Author: "Vrai-Prix — Simon-Pierre Boucher (contact@spboucher.ai)" } });214 doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));215 doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));216 doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));217 doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));218 doc.registerFont("Inter", F("Inter-Regular.ttf"));219 const chunks: Buffer[] = [];220 doc.on("data", (c: Buffer) => chunks.push(c));221 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));222 const L = new Layout(doc);223 const src = est.input.attributeSources;224 const prov = (k: string) => SRC_TXT[src[k] ?? ""] ?? (src[k] ?? "supposé");225226 /* ================================ PAGE 1 — couverture ================================ */227 pageBg(doc);228 doc.rect(0, 0, W, 96).fill(INK);229 logo(doc, M, 30);230 doc.font("JB-Reg").fontSize(7).fillColor(LIME).text("ESTIMATION IMMOBILIÈRE TRANSPARENTE · RAPPORT — MÉTHODE DU COÛT", M, 66, { characterSpacing: 1.2, lineBreak: false });231 doc.font("JB-Reg").fontSize(6).fillColor("#9aa39c").text("COÛT DE REMPLACEMENT À NEUF · DÉPRÉCIATION · TERRAIN — TROISIÈME LECTURE DE LA VALEUR", M, 78, { characterSpacing: 1, lineBreak: false });232 doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")233 .text(`ESTIMATION ${est.id.slice(0, 8).toUpperCase()}`, W - M - 220, 34, { width: 220, align: "right" })234 .text(`GÉNÉRÉ LE ${generated}`, W - M - 220, 46, { width: 220, align: "right" })235 .text(`PRIX AU ${est.priceDate} · MÉTHODE v${est.methodVersion}`, W - M - 220, 58, { width: 220, align: "right" });236 doc.rect(0, 96, W, 3).fill(LIME);237238 let y = 122;239 kicker(doc, "Indication de valeur par la méthode du coût", M, y);240 y += 16;241 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²`;242 doc.font("SG-Bold").fontSize(15).fillColor(INK).text(titre.toUpperCase(), M, y, { width: W - 2 * M });243 y = doc.y + 6;244 doc.font("SG-Bold").fontSize(46).fillColor(INK).text(fmt(est.costApproachValue), M, y);245 y = doc.y + 10;246247 const cols = [248 ["COÛT DE REMPLACEMENT À NEUF", fmt(est.replacementCostNew)],249 ["DÉPRÉCIATION TOTALE", neg(est.depreciation.total)],250 ["VALEUR DU TERRAIN", fmt(est.landValue)],251 ["FOURCHETTE RCN P10-P90", `${fmt(est.range.p10)} → ${fmt(est.range.p90)}`],252 ] as const;253 let cx0 = M;254 for (const [k, v] of cols) {255 doc.font("JB-Bold").fontSize(6.3).fillColor(INK3).text(k, cx0, y, { characterSpacing: 0.6, lineBreak: false });256 const lw = doc.widthOfString(k) + 6;257 doc.font("SG-Bold").fontSize(12).fillColor(INK).text(v, cx0, y + 11, { lineBreak: false });258 cx0 += Math.max(doc.widthOfString(v), lw) + 26;259 }260 const c = est.confidence;261 const confTxt = `CONFIANCE ${c.letter} · ${c.total} / 100 — ${CONF_TXT[c.letter].toUpperCase()}`;262 doc.font("JB-Bold").fontSize(7.5);263 const cw = doc.widthOfString(confTxt) + 20;264 doc.roundedRect(M, y + 34, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[c.letter], INK);265 doc.fillColor(c.letter === "D" ? DANGER : INK).text(confTxt, M + 10, y + 39.5, { lineBreak: false });266 y += 72;267268 // équation269 card(doc, M, y, W - 2 * M, 58, { fill: INK, shadow: false });270 doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("LA FORMULE — JAMAIS MASQUÉE", M + 16, y + 11, { characterSpacing: 1.4 });271 doc.font("JB-Reg").fontSize(8.2).fillColor("#e8e6df").text(272 `V_coût = V_terrain + RCN − D → ${fmt(est.costApproachValue)} = ${fmt(est.landValue)} + ${fmt(est.replacementCostNew)} − ${fmt(est.depreciation.total)}`,273 M + 16, y + 26, { width: W - 2 * M - 32, lineBreak: false, height: 12, ellipsis: true });274 doc.font("JB-Reg").fontSize(7).fillColor("#9fb4bd").text(275 `RCN = Direct ${fmt(est.directCost)} + Indirects ${fmt(est.indirectCost)} + Frais gén. ${fmt(est.contractorOverhead)} + Profit ${fmt(est.contractorProfit)} + Contingence ${fmt(est.contingency)}`,276 M + 16, y + 41, { width: W - 2 * M - 32, lineBreak: false, height: 10, ellipsis: true });277 y += 78;278279 // fiche du bâtiment (3 cartes)280 kicker(doc, "1 · Propriété et bâtiment — chaque caractéristique avec sa provenance", M, y);281 y += 16;282 const sidingTxt = Object.entries(b.siding).filter(([, v]) => (v ?? 0) > 0).map(([k, v]) => `${SIDING[k] ?? k} ${Math.round((v ?? 0) * 100)} %`).join(", ") || "—";283 const groups: [string, [string, string][]][] = [284 ["IDENTIFICATION", [285 ["Mode", MODE[est.input.mode] ?? est.input.mode],286 ["Id / annonce", est.input.propertyId ?? est.input.listingUid ?? "—"],287 ["Municipalité", est.input.municipality ?? "—"],288 ["Type", `${BUILDING_TYPES.find((t) => t.key === b.type)?.fr ?? b.type} (${prov("type")})`],289 ["Qualité", `${qLabel(b.quality)} (${prov("quality")})`],290 ["Logements", String(b.units)],291 ["Année constr.", `${b.yearBuilt ?? "—"} (${prov("yearBuilt")})`],292 ["Localisation", est.location.nameFr],293 ]],294 ["GÉOMÉTRIE", [295 ["Aire d'étages", `${num(b.grossFloorAreaSqft)} pi² (${prov("grossFloorAreaSqft")})`],296 ["Étages", `${b.stories} (${prov("stories")})`],297 ["Sous-sol", `${BASEMENT[b.basement] ?? b.basement} (${prov("basement")})`],298 ["Fondation", `${FOUND[b.foundation] ?? b.foundation} (${prov("foundation")})`],299 ["Garage", `${GARAGE[b.garage.type]}${b.garage.spaces ? ` ${b.garage.spaces} pl.` : ""} (${prov("garage")})`],300 ["Toit", `${ROOF[b.roof] ?? b.roof}, ${GEOM[b.roofGeometry] ?? b.roofGeometry} ${b.roofPitch}/12 (${prov("roof")})`],301 ["Fenêtres", `${WIN[b.windows] ?? b.windows}${b.windowCount != null ? ` × ${b.windowCount}` : ""} (${prov("windows")})`],302 ["Revêtement", `${sidingTxt} (${prov("siding")})`],303 ]],304 ["INTÉRIEUR ET MÉCANIQUE", [305 ["Chauffage", `${HEAT[b.heating] ?? b.heating} (${prov("heating")})`],306 ["Clim. / VRC", `${b.hasAirConditioning ? "clim." : "—"} / ${b.hasAirExchanger ? "VRC" : "—"}`],307 ["Cuisines", `${b.kitchens} · ${qLabel(b.kitchenQuality)} (${prov("kitchenQuality")})`],308 ["Salles de bain", `${b.bathrooms} + ${b.powderRooms} s. d'eau · ${qLabel(b.bathroomQuality)} (${prov("bathrooms")})`],309 ["Terrasse", b.deckSqft ? `${num(b.deckSqft)} pi²` : "—"],310 ["Entrée", b.drivewaySqft ? `${num(b.drivewaySqft)} pi² (${b.driveway})` : "—"],311 ["Piscine", b.pool === "none" ? "—" : b.pool === "inground" ? "creusée" : "hors terre"],312 ["Aménagement", b.landscapingSqft ? `${num(b.landscapingSqft)} pi²` : "—"],313 ]],314 ];315 const gW = (W - 2 * M - 2 * 14) / 3;316 const cellH = 38;317 const gH = 4 * (cellH + 6) + 34;318 groups.forEach(([title, rows], gi) => {319 const gx = M + gi * (gW + 14);320 card(doc, gx, y, gW, gH);321 doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP).text(title, gx + 12, y + 11, { characterSpacing: 1.2, lineBreak: false });322 doc.moveTo(gx + 12, y + 24).lineTo(gx + gW - 12, y + 24).dash(2, { space: 3 }).lineWidth(0.8).stroke(LINE).undash();323 const cw2 = (gW - 24 - 6) / 2;324 rows.forEach(([k, v], i) => {325 const col = i % 2, row = Math.floor(i / 2);326 kvCell(doc, gx + 12 + col * (cw2 + 6), y + 32 + row * (cellH + 6), cw2, cellH, k, v);327 });328 });329 y += gH + 14;330 doc.font("JB-Reg").fontSize(6.3).fillColor(INK3).text(331 "PROVENANCE : rôle MAMH = registre officiel · saisi = utilisateur · 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).",332 M, y, { width: W - 2 * M, characterSpacing: 0.2, lineGap: 1 });333334 /* ================================ PAGE 2 — hypothèses & localisation ================================ */335 L.newPage();336 L.section("2 · Hypothèses — chaque pourcentage est visible, modifiable et expliqué");337 const p = est.input.params;338 L.table(339 [{ h: "Poste", w: 40 }, { h: "Taux", w: 10, align: "right", mono: true }, { h: "Base de calcul", w: 30 }, { h: "Explication (hypothèse documentée)", w: 60 }],340 [341 ...est.indirect.map((i) => [i.labelFr, pct(i.pct, 1), "coût direct", INDIRECT_LABELS[i.key].noteFr]),342 ["Frais généraux entrepreneur", pct(p.overheadPct, 1), "direct + indirects", "Frais fixes de l'entreprise (bureau, gestion) — distincts du profit."],343 ["Profit entrepreneur", pct(p.profitPct, 1), "direct + indirects + FG", "Rémunération du risque de l'entrepreneur général."],344 ["Contingence", pct(p.contingencyPct, 1), "direct + indirects", "Réserve pour imprévus de chantier — ni profit ni frais indirects."],345 ],346 { rowH: 15 }347 );348 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 %.`);349 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 });350351 L.section("8 · Localisation — facteurs distincts matériaux / main-d'œuvre / équipement");352 const loc = est.location;353 L.table(354 [{ 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 }],355 [[`${loc.nameFr} (${loc.code})`, loc.materialFactor.toFixed(3), loc.labourFactor.toFixed(3), loc.equipmentFactor.toFixed(3), loc.overallFactor.toFixed(3), `${loc.confidence}/100`]]356 );357 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.`);358359 /* ================================ 4 · direct par catégorie ================================ */360 L.section("4 · Coût direct par catégorie — matériaux, main-d'œuvre, équipement");361 const maxAdj = Math.max(1, ...est.categories.map((k) => k.adjusted));362 L.table(363 [{ 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 }],364 est.categories.map((k) => [k.labelFr, fmt(k.material), fmt(k.labour), fmt(k.equipment), fmt(k.adjusted), pct(k.sharePct, 1)]),365 { total: ["COÛTS DIRECTS", fmt(est.directMaterial), fmt(est.directLabour), fmt(est.directEquipment), fmt(est.directCost), "100 %"] }366 );367 // barres368 L.ensure(est.categories.length * 11 + 14);369 est.categories.forEach((k) => {370 const trackX = M + 150, trackW = W - 2 * M - 150 - 70;371 doc.font("JB-Reg").fontSize(6.4).fillColor(INK).text(k.labelFr, M, L.y + 1, { width: 145, lineBreak: false, height: 9, ellipsis: true });372 doc.rect(trackX, L.y, trackW, 8).fill(SURFACE2);373 const wM = (k.material / maxAdj) * trackW, wL = (k.labour / maxAdj) * trackW, wE = (k.equipment / maxAdj) * trackW;374 doc.rect(trackX, L.y, wM, 8).fill(INK);375 doc.rect(trackX + wM, L.y, wL, 8).fill(GREEN);376 doc.rect(trackX + wM + wL, L.y, wE, 8).fill(LIME);377 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 });378 L.y += 11;379 });380 doc.font("JB-Reg").fontSize(6.2).fillColor(INK3).text("■ MATÉRIAUX (encre) · ■ MAIN-D'ŒUVRE (bordeaux) · ■ ÉQUIPEMENT (rouge)", M, L.y + 2, { characterSpacing: 0.3 });381 L.y += 16;382383 /* ================================ 5 · indirects + 9 · RCN ================================ */384 L.section("5 · Indirects, frais généraux, profit, contingence → 9 · RCN");385 L.table(386 [{ h: "Poste", w: 55 }, { h: "Taux", w: 15, align: "right", mono: true }, { h: "Montant", w: 30, align: "right", mono: true }],387 [388 ["Coûts directs localisés", "", fmt(est.directCost)],389 ...est.indirect.map((i) => [`Indirect — ${i.labelFr}`, pct(i.pct, 1), fmt(i.amount)]),390 ["Sous-total coûts indirects", pct(est.indirect.reduce((s, i) => s + i.pct, 0), 1), fmt(est.indirectCost)],391 ["Frais généraux entrepreneur", pct(p.overheadPct, 1), fmt(est.contractorOverhead)],392 ["Profit entrepreneur", pct(p.profitPct, 1), fmt(est.contractorProfit)],393 ["Contingence", pct(p.contingencyPct, 1), fmt(est.contingency)],394 ],395 { total: ["COÛT DE REMPLACEMENT À NEUF (RCN)", "", fmt(est.replacementCostNew)], highlight: (i) => i === 0 || i === est.indirect.length + 1 }396 );397 L.ensure(70);398 const tW3 = (W - 2 * M - 28) / 3;399 [["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) => {400 const x = M + i * (tW3 + 14);401 card(doc, x, L.y, tW3, 52);402 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 });403 doc.font("SG-Bold").fontSize(17).fillColor(INK).text(v, x + 12, L.y + 24, { lineBreak: false, width: tW3 - 24, height: 22, ellipsis: true });404 });405 L.y += 74;406407 /* ================================ 13 · fourchette ================================ */408 L.section("13 · Fourchette de coût — P10 · central · P90");409 L.ensure(60);410 {411 const r = est.range;412 const lo = r.p10 * 0.96, hi = r.p90 * 1.04;413 const tx = M, tw = W - 2 * M;414 const X = (v: number) => tx + ((v - lo) / (hi - lo)) * tw;415 doc.rect(tx, L.y + 14, tw, 10).fill(SURFACE2);416 doc.rect(X(r.p10), L.y + 14, X(r.p90) - X(r.p10), 10).fill(LIME_SOFT);417 doc.rect(X(r.p10), L.y + 10, 1.5, 18).fill(GREEN);418 doc.rect(X(r.p90), L.y + 10, 1.5, 18).fill(GREEN);419 doc.rect(X(est.replacementCostNew) - 1.5, L.y + 6, 3, 26).fill(INK);420 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 });421 doc.text(`P90 ${fmt(r.p90)}`, X(r.p90) - 40, L.y + 32, { width: 80, align: "center", lineBreak: false });422 doc.fillColor(INK).text(`RCN ${fmt(est.replacementCostNew)}`, X(est.replacementCostNew) - 50, L.y - 2, { width: 100, align: "center", lineBreak: false });423 L.y += 46;424 }425 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 σ.`);426427 /* ================================ 10 · dépréciation ================================ */428 L.newPage();429 L.section("10 · Dépréciation — physique, fonctionnelle, externe");430 const d = est.depreciation;431 L.table(432 [{ h: "Élément", w: 60 }, { h: "Valeur", w: 40, align: "right", mono: true }],433 [434 ["Âge chronologique", d.chronologicalAge != null ? `${d.chronologicalAge} ans` : "—"],435 ["Âge effectif retenu", d.effectiveAge != null ? `${num(d.effectiveAge, 1)} ans (${SRC_TXT[est.input.depreciation.effectiveAgeSource] ?? est.input.depreciation.effectiveAgeSource})` : "—"],436 ["Vie économique", `${d.economicLife} ans`],437 ["Méthode", d.method === "components" ? "composante par composante" : "âge-vie simple"],438 ["Détérioration physique", `${pct(d.physicalPct, 1)} → ${fmt(d.physical)}`],439 ]440 );441 if (d.method === "components" && d.components.length) {442 L.table(443 [{ 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 }],444 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)]),445 { 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))] }446 );447 L.para("Règle : âge effectif de la composante = ratio de condition × vie économique (règles documentées 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 });448 }449 const fo = est.input.depreciation.functional;450 L.table(451 [{ h: "Désuétude", w: 34 }, { h: "Type", w: 16 }, { h: "Détail", w: 30 }, { h: "Montant", w: 20, align: "right", mono: true }],452 [453 ...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)]),454 ["Désuétude fonctionnelle (total)", "", `${fo.length} déficience(s)`, neg(d.functional)],455 ["Désuétude externe", "", est.input.depreciation.externalNote || "aucune preuve saisie → 0 $", neg(d.external)],456 ],457 { total: ["DÉPRÉCIATION TOTALE", "", `${pct(est.replacementCostNew ? (d.total / est.replacementCostNew) * 100 : 0, 1)} du RCN`, neg(d.total)] }458 );459460 /* ================================ 11 · terrain + 12 · valeur ================================ */461 L.section("11 · Valeur du terrain");462 const land = est.input.land;463 const LANDSRC: Record<string, string> = { role: `Rôle d'évaluation foncière ${land.rollYear ?? 2026} (MAMH)`, user: "Valeur saisie par l'utilisateur", market: "Valeur marchande (ventes de terrains)", residual: "Technique du résiduel", none: "Aucune valeur — 0 $ (à compléter)" };464 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 || "—"]]);465 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. Vous pouvez la conserver, la remplacer ou la documenter par une autre méthode.", { color: DANGER, size: 7.4 });466467 L.section("12 · Indication de valeur par la méthode du coût");468 L.table(469 [{ h: "Poste", w: 60 }, { h: "Montant", w: 40, align: "right", mono: true }],470 [471 ...est.categories.map((k) => [k.labelFr, fmt(k.adjusted)]),472 ["Coûts directs", fmt(est.directCost)],473 ["Coûts indirects", fmt(est.indirectCost)],474 ["Frais généraux", fmt(est.contractorOverhead)],475 ["Profit", fmt(est.contractorProfit)],476 ["Contingence", fmt(est.contingency)],477 ["COÛT DE REMPLACEMENT À NEUF", fmt(est.replacementCostNew)],478 ["Détérioration physique", neg(d.physical)],479 ["Désuétude fonctionnelle", neg(d.functional)],480 ["Désuétude externe", neg(d.external)],481 ["Valeur dépréciée du bâtiment", fmt(d.depreciatedImprovementValue)],482 ["Valeur du terrain", fmt(est.landValue)],483 ],484 { total: ["INDICATION PAR LE COÛT", fmt(est.costApproachValue)], highlight: (i) => [est.categories.length, est.categories.length + 5, est.categories.length + 9].includes(i) }485 );486487 /* ================================ 14 · confiance + benchmarks ================================ */488 L.section("14 · Indice de confiance — décomposition");489 L.table(490 [{ h: "Critère", w: 50 }, { h: "Score", w: 20, align: "right", mono: true }, { h: "Maximum", w: 30, align: "right", mono: true }],491 [["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"]],492 { total: [`TOTAL — ${c.letter} · ${CONF_TXT[c.letter]}`, String(c.total), "100"] }493 );494 c.notesFr.forEach((n) => L.bullet(n));495 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.`);496 L.gap(4);497 L.section("Validation externe — benchmarks");498 if (est.benchmarks.length) {499 L.table(500 [{ h: "Source", w: 26 }, { h: "Type / marché", w: 26 }, { h: "Plage", w: 20, align: "right", mono: true }, { h: "Vrai-Prix", w: 14, align: "right", mono: true }, { h: "État", w: 14 }],501 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"])502 );503 L.para("Le benchmark ne modifie jamais l'estimation : un écart important est signalé, pas corrigé.", { size: 7.2, color: INK3 });504 } else {505 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 });506 }507 if (est.otherReadings) {508 const o = est.otherReadings;509 L.section("Trois lectures de la valeur — à comparer, jamais à moyenner");510 L.table(511 [{ h: "Lecture", w: 50 }, { h: "Valeur", w: 25, align: "right", mono: true }, { h: "vs coût", w: 25, align: "right", mono: true }],512 [513 ["Modèle hédonique (LightGBM)", fmt(o.hedonic), o.hedonic ? pct(((o.hedonic - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],514 ["Comparables ajustés", fmt(o.comparables), o.comparables ? pct(((o.comparables - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],515 ["Mesure Vrai-Prix (hybride 65/35)", fmt(o.hybrid), o.hybrid ? pct(((o.hybrid - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],516 ["Méthode du coût (ce rapport)", fmt(est.costApproachValue), "—"],517 ...(o.askingPrice ? [["Prix demandé", fmt(o.askingPrice), pct(((o.askingPrice - est.costApproachValue) / est.costApproachValue) * 100, 1)]] : []),518 ["Rôle 2026 — valeur totale", fmt(o.rollValue), o.rollValue ? pct(((o.rollValue - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"],519 ["Rôle 2026 — bâtiment / terrain", `${fmt(o.rollBuilding)} / ${fmt(o.rollLand)}`, ""],520 ]521 );522 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 est de comparer les approches, pas de faire une moyenne naïve.`, { size: 7.2, color: INK3 });523 }524525 /* ================================ 3 + 6 · assemblages ================================ */526 L.newPage();527 L.section("3 · Assemblages retenus (6 · quantités, coûts unitaires, totaux)");528 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 });529 const kindOf = (l: CostEstimate["lines"][number]): string => {530 const kinds = l.unitDetail.components.filter((k) => k.itemCode).map((k) => k.provenance.kind);531 if (!kinds.length) return "◐";532 if (kinds.every((k) => k === "observed" || k === "official")) return "●";533 if (kinds.some((k) => k === "observed" || k === "official")) return "◐";534 if (kinds.some((k) => k === "indexed")) return "○";535 return "◇";536 };537 L.table(538 [{ 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 }],539 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)]),540 { 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), ""] }541 );542 // descriptions des assemblages543 L.section("Description des assemblages");544 const seen = new Set<string>();545 for (const l of est.lines) {546 if (seen.has(l.assemblyCode)) continue;547 seen.add(l.assemblyCode);548 const a = est.lines.find((x) => x.assemblyCode === l.assemblyCode)!;549 const desc = a.unitDetail.components.filter((k) => k.itemCode).map((k) => `${k.nameFr} ${num(k.quantity, 3)} ${unitLabel(k.unit)}`).join(" · ");550 L.para(`${l.nameFr} [${l.assemblyCode}, par ${unitLabel(l.unit)}] — ${desc || "main-d'œuvre/équipement seulement"}.`, { size: 6.8, color: INK2, gap: 3 });551 }552553 /* ================================ 7 · main-d'œuvre ================================ */554 L.newPage();555 L.section("7 · Main-d'œuvre — heures et coût par métier (taux employeur complet)");556 const trades = new Map<string, { h: number; cost: number; rate: number | null; src: string; date: string; kind: string }>();557 for (const l of est.lines) for (const k of l.unitDetail.components) {558 if (!k.labourHours) continue;559 const t = k.trade ?? "—";560 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)" };561 cur.h += k.labourHours * l.quantity;562 cur.cost += k.labourCost * l.quantity * loc.labourFactor;563 trades.set(t, cur);564 }565 const trows = [...trades.entries()].sort((a, b) => b[1].cost - a[1].cost);566 L.table(567 [{ 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 }],568 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]),569 { total: ["TOTAL MAIN-D'ŒUVRE", num(trows.reduce((s, [, v]) => s + v.h, 0), 0), "", fmt(est.directLabour), "", ""] }570 );571 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 });572573 /* ================================ 15 · sources ================================ */574 L.section("15 · Sources — chaque prix important est retraçable");575 const srcMap = new Map<string, { kind: string; date: string; url: string; n: number }>();576 for (const l of est.lines) for (const k of l.unitDetail.components) {577 if (!k.itemCode) continue;578 for (const nm of k.provenance.source.split(", ")) {579 const key = nm.trim();580 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 };581 cur.n++;582 srcMap.set(key, cur);583 }584 if (k.rateProvenance) {585 const key = k.rateProvenance.source;586 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 };587 cur.n++;588 srcMap.set(key, cur);589 }590 }591 L.table(592 [{ 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 }],593 [...srcMap.entries()].sort((a, b) => b[1].n - a[1].n).map(([k, v]) => [k, v.kind, v.date, String(v.n), v.url]),594 { rowH: 13 }595 );596 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 });597598 /* ================================ 16 · limites ================================ */599 L.section("16 · Limites");600 [601 "Estimation indicative : les coûts présentés sont des estimations statistiques et ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ).",602 "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.",603 "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 ».",604 "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.",605 "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.",606 "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).",607 ...(est.warnings.filter((w) => !w.startsWith("localisation")).map((w) => `Avertissement du moteur : ${w}.`)),608 ].forEach((t) => L.bullet(t));609610 /* ---- pieds de page avec total ---- */611 const range = doc.bufferedPageRange();612 for (let i = range.start; i < range.start + range.count; i++) {613 doc.switchToPage(i);614 pageFooter(doc, i + 1, range.count, generated);615 }616 doc.end();617 return done;618}619