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%
42.7 KB · 611 lines typescript
Raw Blame History
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