SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
43.0 KB · 619 lines typescript
Raw Blame History
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