SPB Git

spb/valoplex Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

TypeScript 90.3% Python 7.1% CSS 2.5%
22.4 KB · 455 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Rapport PDF ValoPlex — 100 % vectoriel (pdfkit), identité « éditorial sharp » :4 * logo encre/lime, fiche complète du rôle, historique en barres, plan radar des5 * comparables dessiné dans le PDF, tableau des ajustements. Format Lettre.6 */7import PDFDocument from "pdfkit";8import path from "path";9import type { UnitEstimate } from "./estimator";10import { buildProforma } from "./proforma";1112function doorsLabelPdf(n: number | null): string {13  if (!n) return "PLEX";14  const map: Record<number, string> = { 2: "DUPLEX", 3: "TRIPLEX", 4: "QUADRUPLEX", 5: "QUINTUPLEX", 6: "SIXPLEX" };15  return map[n] ?? `MULTI ${n} PORTES`;16}1718const INK = "#141814";19const INK3 = "#8b928c";20const PAPER = "#f5f3ee";21const SURFACE2 = "#faf9f5";22const GREEN = "#b25f16";23const GREEN_DEEP = "#8a4710";24const LIME = "#ff9f45";25const LIME_SOFT = "#ffedd9";26const AMBER_SOFT = "#fdf3e2";27const DANGER_SOFT = "#fbe9e7";28const LINE = "#dedcd4";2930const W = 612;31const CW3 = 612 - 2 * 44;32const H = 792;33const M = 44; // marge3435const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);3637const fmt = (v: number | null | undefined) =>38  v == null39    ? "—"40    : new Intl.NumberFormat("fr-CA", {41        style: "currency",42        currency: "CAD",43        maximumFractionDigits: 0,44      }).format(v);4546const signed = (v: number) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)))}`;47const fmtDist = (m: number) => (m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(1)} km`);4849const CONF_BG: Record<string, string> = { A: LIME, B: LIME_SOFT, C: AMBER_SOFT, D: DANGER_SOFT };50const CONF_TXT: Record<string, string> = {51  A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable — marché mince",52};5354type Doc = InstanceType<typeof PDFDocument>;5556function 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("Valo", x, y, { lineBreak: false });60  const w = doc.widthOfString("Valo");61  const bx = x + w + 4 * scale;62  doc.save();63  doc.rotate(-3, { origin: [bx, y + 12 * scale] });64  const bw = doc.widthOfString("Plex") + 12 * scale;65  doc.roundedRect(bx, y - 3 * scale, bw, 30 * scale, 5 * scale).fill(LIME);66  doc.fillColor(INK).text("Plex", 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)74    .text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false });75}7677function pageFrame(doc: Doc, page: number, total: number, generated: string) {78  doc.rect(0, 0, W, H).fill(PAPER);79  // filet de bas de page80  doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK);81  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(82    "WWW.VALOPLEX.COM — ESTIMATION STATISTIQUE À TITRE INDICATIF · NE REMPLACE PAS UNE ÉVALUATION PROFESSIONNELLE · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI",83    M, H - 38, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false }84  );85  doc.font("JB-Bold").fontSize(7.5).fillColor(INK)86    .text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" });87  doc.font("JB-Reg").fontSize(6.5).fillColor(INK3)88    .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).fillAndStroke(opts?.fill ?? "#ffffff", INK);94  doc.lineWidth(1.3);95}9697function kvCell(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string) {98  doc.lineWidth(0.8);99  doc.roundedRect(x, y, w, h, 4).fillAndStroke(SURFACE2, LINE);100  doc.font("JB-Bold").fontSize(5.8).fillColor(INK3)101    .text(k.toUpperCase(), x + 7, y + 6, { width: w - 14, characterSpacing: 0.7, height: 8, ellipsis: true });102  doc.font("SG-Bold").fontSize(9.2).fillColor(INK)103    .text(v, x + 7, y + 16.5, { width: w - 14, height: h - 20, ellipsis: true });104  doc.lineWidth(1.3);105}106107/** Plan radar vectoriel : sujet au centre, comparables par azimut/distance. */108function radar(doc: Doc, cx: number, cy: number, R: number, data: UnitEstimate) {109  const u = data.unit!;110  const comps = data.result.comps;111  const cos = Math.cos((u.lat * Math.PI) / 180);112  const pts = comps.map((c) => ({113    ...c,114    x: (c.lng - u.lng) * 111320 * cos,115    y: (c.lat - u.lat) * 110574,116  }));117  const maxM = Math.max(120, ...pts.map((p) => Math.hypot(p.x, p.y)));118  const ringSets = [119    [100, 250, 500], [250, 500, 1000], [500, 1000, 2000],120    [1000, 2500, 5000], [2500, 5000, 10000], [5000, 10000, 20000], [10000, 25000, 50000],121  ];122  const rings = ringSets.find((s) => maxM <= s[2]) ?? ringSets[ringSets.length - 1];123  const k = R / rings[2];124125  // cadre + grille126  doc.save();127  doc.roundedRect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16), 8).clip();128  doc.rect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16)).fill("#ffffff");129  doc.lineWidth(0.5).strokeColor("#eceae3");130  for (let g = -R - 16; g <= R + 16; g += 24) {131    doc.moveTo(cx + g, cy - R - 16).lineTo(cx + g, cy + R + 16).stroke();132    doc.moveTo(cx - R - 16, cy + g).lineTo(cx + R + 16, cy + g).stroke();133  }134  // anneaux135  rings.forEach((m, i) => {136    const rr = m * k;137    doc.lineWidth(i === 2 ? 1.3 : 0.8).strokeColor(i === 2 ? INK : "#b9b7ae");138    if (i !== 2) doc.dash(3, { space: 3 });139    doc.circle(cx, cy, rr).stroke();140    doc.undash();141    doc.font("JB-Reg").fontSize(5.5).fillColor(INK3)142      .text(fmtDist(m), cx + rr * 0.7071 + 2, cy - rr * 0.7071 - 7, { lineBreak: false });143  });144  // croix145  doc.lineWidth(0.6).strokeColor("#d5d3ca");146  doc.moveTo(cx, cy - R).lineTo(cx, cy + R).stroke();147  doc.moveTo(cx - R, cy).lineTo(cx + R, cy).stroke();148  // comparables numérotés149  pts.forEach((p, i) => {150    const px = cx + p.x * k;151    const py = cy - p.y * k;152    const rr = 6.5 + p.weight * 3.5;153    doc.circle(px, py, rr).lineWidth(1.1).fillAndStroke(GREEN, INK);154    doc.font("JB-Bold").fontSize(rr > 8.5 ? 7 : 6).fillColor(PAPER)155      .text(String(i + 1), px - rr, py - 3, { width: 2 * rr, align: "center", lineBreak: false });156  });157  // sujet : losange lime158  doc.save().translate(cx, cy).rotate(45);159  doc.rect(-8, -8, 16, 16).lineWidth(1.6).fillAndStroke(LIME, INK);160  doc.rect(-2.5, -2.5, 5, 5).fill(INK);161  doc.restore();162  // nord163  doc.save().translate(cx + R + 2, cy - R - 2);164  doc.path("M0,-9 L4,5 L0,2 L-4,5 Z").fill(INK);165  doc.font("JB-Bold").fontSize(6).fillColor(INK).text("N", -2.2, 8, { lineBreak: false });166  doc.restore();167  doc.restore();168  // bordure du cadre169  doc.roundedRect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16), 8).lineWidth(1.3).stroke(INK);170}171172export async function buildReport(data: UnitEstimate): Promise<Buffer> {173  const u = data.unit!;174  const s = u.specs;175  const r = data.result;176  const generated = new Date().toISOString().slice(0, 16).replace("T", " ");177178  const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: `ValoPlex — ${u.adresse ?? ""}` } });179  doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf"));180  doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.ttf"));181  doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf"));182  doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf"));183  doc.registerFont("Inter", F("Inter-Regular.ttf"));184185  const chunks: Buffer[] = [];186  doc.on("data", (c: Buffer) => chunks.push(c));187  const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));188189  /* =============================== PAGE 1 =============================== */190  pageFrame(doc, 1, 3, generated);191192  // bandeau encre193  doc.rect(0, 0, W, 96).fill(INK);194  logo(doc, M, 30);195  doc.font("JB-Reg").fontSize(7).fillColor(LIME)196    .text("RAPPORT D'ÉVALUATION · QUÉBEC", M, 66, { characterSpacing: 1.6, lineBreak: false });197  doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c")198    .text(`ID ${u.id}`, W - M - 200, 34, { width: 200, align: "right" })199    .text(`GÉNÉRÉ LE ${generated}`, W - M - 200, 46, { width: 200, align: "right" })200    .text(`MATRICULE ${s.matricule ?? "—"}`, W - M - 200, 58, { width: 200, align: "right" });201  doc.rect(0, 96, W, 3).fill(LIME);202203  // adresse + estimation204  let y = 122;205  kicker(doc, "Valeur estimée au marché", M, y);206  y += 16;207  doc.font("SG-Bold").fontSize(15).fillColor(INK)208    .text(`${(u.adresse ?? "").toUpperCase()}${s.apt ? " APP. " + s.apt : ""} · ${(u.municipalite ?? "").toUpperCase()}`,209      M, y, { width: W - 2 * M });210  y = doc.y + 6;211  doc.font("SG-Bold").fontSize(46).fillColor(INK).text(fmt(r.estimate), M, y);212  y = doc.y + 10;213214  // rangée fourchette / confiance / rôle / écart215  const rowY = y;216  const doorsN = u.nbLogements ?? 0;217  const cols = [218    ["GABARIT", doorsLabelPdf(doorsN)],219    ["VALEUR / PORTE", doorsN >= 2 ? fmt(Math.round(r.estimate / doorsN)) : "—"],220    ["FOURCHETTE CALIBRÉE (80 %)", `${fmt(r.low)} → ${fmt(r.high)}`],221    ["ÉVALUATION MUNICIPALE 2026", fmt(u.valeurRole)],222    ["ÉCART VS RÔLE", u.valeurRole ? `${((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %` : "—"],223  ] as const;224  let cx0 = M;225  for (const [k, v] of cols) {226    doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text(k, cx0, rowY, { characterSpacing: 0.8, lineBreak: false });227    const lw = doc.widthOfString(k) + cols.length * 0.8;228    doc.font("SG-Bold").fontSize(13).fillColor(INK).text(v, cx0, rowY + 11, { lineBreak: false });229    cx0 += Math.max(doc.widthOfString(v), lw) + 34;230  }231  // pastille confiance232  const confTxt = `CONFIANCE ${r.confidenceLevel} · ${r.confidencePct} % — ${CONF_TXT[r.confidenceLevel].toUpperCase()}`;233  doc.font("JB-Bold").fontSize(7.5);234  const cw = doc.widthOfString(confTxt) + 20;235  doc.roundedRect(M, rowY + 34, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[r.confidenceLevel], INK);236  doc.fillColor(r.confidenceLevel === "D" ? "#b3423a" : INK).text(confTxt, M + 10, rowY + 39.5, { lineBreak: false });237238  // fiche de la propriété239  y = rowY + 72;240  kicker(doc, "Fiche de la propriété — registre officiel (rôle 2026)", M, y);241  y += 16;242  const groups: [string, [string, string][]][] = [243    ["BÂTIMENT", [244      ["Année constr.", u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? " (est.)" : ""}` : "—"],245      ["Aire étages", u.aireEtagesM2 ? `${u.aireEtagesM2} m²` : "—"],246      ["Étages", u.specs.nbEtages != null ? String(u.specs.nbEtages) : "—"],247      ["Genre", s.genreConstruction ?? "—"],248      ["Lien phys.", s.lienPhysique ?? "—"],249      ["Logements", u.nbLogements != null ? String(u.nbLogements) : "—"],250      ["Locaux n-rés.", s.nbLocauxNonResid != null ? String(s.nbLocauxNonResid) : "—"],251      ["Chambres loc.", s.nbChambresLocatives != null ? String(s.nbChambresLocatives) : "—"],252    ]],253    ["TERRAIN", [254      ["Superficie", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m²` : "—"],255      ["Mes. frontale", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"],256      ["CUBF", s.cubf ? String(s.cubf) : "—"],257      ["Usage", s.cubfLibelle ?? "—"],258      ["Unité voisin.", s.uniteVoisinage || "—"],259      ["Arrond.", s.arrond || "—"],260    ]],261    ["RÔLE D'ÉVALUATION", [262      ["Val. terrain", fmt(s.valeurTerrain)],263      ["Val. bâtiment", fmt(s.valeurBatiment)],264      ["Val. totale", fmt(u.valeurRole)],265      ["Rôle antér.", fmt(s.valeurAnterieure)],266      ["Cond. marché", s.datCondMarche ?? "—"],267      ["Adresses", s.nAdresses != null ? String(s.nAdresses) : "—"],268    ]],269  ];270  const gW = (W - 2 * M - 2 * 14) / 3;271  const cellH = 30;272  const gH = 4 * (cellH + 6) + 34;273  groups.forEach(([title, rows], gi) => {274    const gx = M + gi * (gW + 14);275    card(doc, gx, y, gW, gH);276    doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP)277      .text(title, gx + 12, y + 11, { characterSpacing: 1.2, lineBreak: false });278    doc.moveTo(gx + 12, y + 24).lineTo(gx + gW - 12, y + 24).dash(2, { space: 3 }).lineWidth(0.8).stroke(LINE).undash();279    const cw2 = (gW - 24 - 6) / 2;280    rows.forEach(([k, v], i) => {281      const col = i % 2, row = Math.floor(i / 2);282      kvCell(doc, gx + 12 + col * (cw2 + 6), y + 32 + row * (cellH + 6), cw2, cellH, k, v);283    });284  });285  y += gH + 24;286287  // adresses officielles des portes288  if (s.portesAdresses.length > 0) {289    doc.font("JB-Bold").fontSize(6.2).fillColor(GREEN_DEEP)290      .text("PORTES OFFICIELLES (RÔLE) : ", M, y, { characterSpacing: 0.6, lineBreak: false });291    doc.font("JB-Reg").fontSize(6.2).fillColor(INK3)292      .text(s.portesAdresses.slice(0, 8).join("  ·  ") +293        (s.portesAdresses.length > 8 ? `  (+${s.portesAdresses.length - 8})` : ""),294        M + 118, y, { width: W - 2 * M - 118, height: 14, ellipsis: true });295    y += 16;296  }297298  // historique299  kicker(doc, "Valeur estimée par année — 2021 → 2026", M, y);300  y += 16;301  const hist = u.history;302  const hMax = Math.max(...hist.map((h) => h.value ?? 0), 1);303  const histH = hist.length * 21 + 24;304  card(doc, M, y, W - 2 * M, histH);305  hist.forEach((h, i) => {306    const ly = y + 14 + i * 21;307    doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(String(h.year), M + 14, ly + 2, { lineBreak: false });308    const trackX = M + 52, trackW = W - 2 * M - 52 - 100;309    doc.rect(trackX, ly, trackW, 12).fill(SURFACE2);310    doc.rect(trackX, ly, Math.max(2, ((h.value ?? 0) / hMax) * trackW), 12).fill(h.year === 2026 ? INK : GREEN);311    doc.font("JB-Bold").fontSize(7.5).fillColor(INK)312      .text(fmt(h.value), W - M - 92, ly + 2, { width: 80, align: "right" });313  });314315  /* =============================== PAGE 2 =============================== */316  doc.addPage({ size: "LETTER", margin: 0 });317  pageFrame(doc, 2, 3, generated);318  doc.rect(0, 0, W, 8).fill(INK);319  doc.rect(0, 8, W, 2.5).fill(LIME);320321  y = 34;322  kicker(doc, "Pourquoi ce prix — le calcul, sans boîte noire", M, y);323  y += 16;324  const tileW = (W - 2 * M - 14) / 2;325  card(doc, M, y, tileW, 64);326  doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("MODÈLE HÉDONIQUE PLEX EN RATIO (LIGHTGBM · 91 060 VENTES)", M + 14, y + 11, { characterSpacing: 0.6 });327  doc.font("SG-Bold").fontSize(21).fillColor(INK).text(fmt(r.modelEstimate), M + 14, y + 24);328  doc.font("JB-Reg").fontSize(7).fillColor(GREEN_DEEP).text(`POIDS : ${(r.modelWeight * 100).toFixed(0)} %`, M + 14, y + 49);329  card(doc, M + tileW + 14, y, tileW, 64);330  doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("COMPARABLES AJUSTÉS (MÉDIANE PONDÉRÉE)", M + tileW + 28, y + 11, { characterSpacing: 0.6 });331  doc.font("SG-Bold").fontSize(21).fillColor(INK).text(fmt(r.compsEstimate), M + tileW + 28, y + 24);332  doc.font("JB-Reg").fontSize(7).fillColor(GREEN_DEEP).text(333    `POIDS : ${((1 - r.modelWeight) * 100).toFixed(0)} % · ${r.nCompsUsed} COMPS${r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}`,334    M + tileW + 28, y + 49);335  y += 88;336337  // radar + tableau338  kicker(doc, `Plan des comparables — ${r.nCompsUsed} ventes réelles · azimut + distance`, M, y);339  y += 18;340  const R = 118;341  radar(doc, M + R + 16, y + R + 16, R, data);342343  const tX = M + 2 * (R + 16) + 16;344  const tW = W - M - tX;345  doc.font("JB-Bold").fontSize(6.2).fillColor(INK3);346  let ty = y + 2;347  r.comps.slice(0, 12).forEach((c, i) => {348    const rowH = 22.5;349    if (i % 2 === 0) doc.rect(tX, ty - 2, tW, rowH).fill(SURFACE2);350    doc.circle(tX + 8, ty + 7, 6).lineWidth(0.9).fillAndStroke(GREEN, INK);351    doc.font("JB-Bold").fontSize(6).fillColor(PAPER).text(String(i + 1), tX + 2, ty + 4.5, { width: 12, align: "center", lineBreak: false });352    doc.font("SG-Bold").fontSize(7.3).fillColor(INK)353      .text(`${c.street ?? ""}`, tX + 19, ty, { width: tW - 90, height: 9, ellipsis: true, lineBreak: false });354    doc.font("JB-Reg").fontSize(5.8).fillColor(INK3)355      .text(`${c.date} · ${fmtDist(c.distanceM)} · VENDU ${fmt(c.amount)} · T ${signed(c.adjTime)} · S ${signed(c.adjArea)} · Â ${signed(c.adjAge)}`,356        tX + 19, ty + 10, { width: tW - 78, height: 8, ellipsis: true, lineBreak: false });357    doc.font("SG-Bold").fontSize(8).fillColor(INK)358      .text(fmt(Math.round(c.adjustedPrice)), tX + tW - 68, ty + 3, { width: 68, align: "right", lineBreak: false });359    ty += rowH;360  });361  // légende radar362  const legY = y + 2 * (R + 16) + 8;363  doc.font("JB-Reg").fontSize(6.3).fillColor(INK3).text(364    "◆ = PROPRIÉTÉ ÉVALUÉE (CENTRE) · ● = VENTE COMPARABLE (TAILLE = POIDS) · T/S/Â = AJUSTEMENTS TEMPS / SUPERFICIE / ÂGE",365    M, legY, { width: W - 2 * M, characterSpacing: 0.4 });366367  /* =============================== PAGE 3 : PRO FORMA =============================== */368  doc.addPage({ size: "LETTER", margin: 0 });369  pageFrame(doc, 3, 3, generated);370  doc.rect(0, 0, W, 8).fill(INK);371  doc.rect(0, 8, W, 2.5).fill(LIME);372  y = 34;373  if (doorsN >= 2 && r.estimate > 0) {374    const pf = buildProforma(r.estimate, doorsN, u.valeurRole, u.municipalite);375    kicker(doc, `Pro forma de l'investisseur — taux ${pf.params.tauxHypoPct} % · mise ${pf.params.miseDeFondsPct} % · TGA ${pf.params.tgaPct} %`, M, y);376    y += 18;377    const colW3 = (CW3 - 14) / 2;378    // état des résultats379    card(doc, M, y, colW3, 250);380    doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP).text("ÉTAT DES RÉSULTATS NORMALISÉ (ANNUEL)", M + 12, y + 10, { characterSpacing: 0.8 });381    const lines1: [string, string][] = [382      [`Revenus bruts (${doorsN} portes × ${fmt(Math.round(pf.loyerMoyenMensuel))}/mois)`, fmt(Math.round(pf.revenusBruts))],383      [`Vacance (${pf.params.vacancePct} %)`, fmt(Math.round(pf.vacance))],384      ["Taxes municipales et scolaire", fmt(Math.round(pf.depenses[0].amount + pf.depenses[1].amount))],385      ["Assurances", fmt(Math.round(pf.depenses[2].amount))],386      ["Entretien + gestion", fmt(Math.round(pf.depenses[3].amount + pf.depenses[4].amount))],387      ["Déneigement, énergie, réserve", fmt(Math.round(pf.depenses[5].amount + pf.depenses[6].amount + pf.depenses[7].amount))],388      ["REVENU NET D'EXPLOITATION", fmt(Math.round(pf.rne))],389      ["TGA implicite / MRB", `${pf.tgaImplicitePct.toFixed(2)} % · × ${pf.mrb.toFixed(1)}`],390      ["RNE par porte", fmt(Math.round(pf.rne / doorsN))],391    ];392    lines1.forEach(([k, v], i) => {393      const ly = y + 26 + i * 23;394      const strong = k === "REVENU NET D'EXPLOITATION";395      if (i % 2 === 0) doc.rect(M + 8, ly - 3, colW3 - 16, 21).fill(SURFACE2);396      doc.font(strong ? "JB-Bold" : "Inter").fontSize(strong ? 7.4 : 7.6).fillColor(strong ? INK : "#4d5551")397        .text(k, M + 12, ly, { width: colW3 - 130, height: 18, lineBreak: false, ellipsis: true });398      doc.font("SG-Bold").fontSize(8.4).fillColor(INK)399        .text(v, M + colW3 - 118, ly - 1, { width: 106, align: "right", lineBreak: false });400    });401    // financement + 5 ans402    const x2 = M + colW3 + 14;403    card(doc, x2, y, colW3, 250);404    doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP).text("FINANCEMENT ET PROJECTION 5 ANS", x2 + 12, y + 10, { characterSpacing: 0.8 });405    const lines2: [string, string][] = [406      [`Hypothèque (${100 - pf.params.miseDeFondsPct} %)`, fmt(Math.round(pf.hypotheque))],407      [`Paiement mensuel (${pf.params.amortAns} ans)`, fmt(Math.round(pf.paiementMensuelHypo))],408      ["Cashflow avant impôt / an", fmt(Math.round(pf.cashflowAnnuel))],409      ["Cashflow / porte / mois · DSCR", `${fmt(Math.round(pf.cashflowMensuelParPorte))} · ${pf.dscr.toFixed(2)}`],410      ["Capital remboursé an 1", fmt(Math.round(pf.capitalAn1))],411      ["Rendement global an 1", `${pf.rendementTotalAn1Pct.toFixed(1)} %`],412      [`Équité à 5 ans (appr. ${pf.params.appreciationPct} %/an)`, fmt(Math.round(pf.equite5Ans))],413      ["Gain total 5 ans", fmt(Math.round(pf.gainTotal5Ans))],414      ["Loyer de point mort / marge", `${fmt(Math.round(pf.loyerPointMort))} · ${pf.margeSecuriteLoyerPct.toFixed(0)} %`],415    ];416    lines2.forEach(([k, v], i) => {417      const ly = y + 26 + i * 23;418      if (i % 2 === 0) doc.rect(x2 + 8, ly - 3, colW3 - 16, 21).fill(SURFACE2);419      doc.font("Inter").fontSize(7.6).fillColor("#4d5551")420        .text(k, x2 + 12, ly, { width: colW3 - 130, height: 18, lineBreak: false, ellipsis: true });421      doc.font("SG-Bold").fontSize(8.4).fillColor(INK)422        .text(v, x2 + colW3 - 118, ly - 1, { width: 106, align: "right", lineBreak: false });423    });424    y += 264;425    // liquidités requises426    card(doc, M, y, CW3, 58, { fill: INK });427    doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("LIQUIDITÉS REQUISES — TOUT LE KIT", M + 14, y + 10, { characterSpacing: 1 });428    doc.font("Inter").fontSize(8).fillColor("#e8e6df").text(429      `Mise de fonds ${fmt(Math.round(pf.miseDeFonds))} · Droits de mutation ${fmt(pf.droitsMutation)} · Notaire ${fmt(pf.fraisNotaire)} · Inspection ${fmt(pf.fraisInspection)}`,430      M + 14, y + 24, { lineBreak: false });431    doc.font("SG-Bold").fontSize(14).fillColor(LIME)432      .text(`TOTAL ${fmt(Math.round(pf.liquiditesRequises))}`, M + 14, y + 37, { lineBreak: false });433    y += 72;434    doc.font("JB-Reg").fontSize(6).fillColor(INK3).text(435      "PRO FORMA INDICATIF : LOYER IMPLICITE DÉRIVÉ DE LA VALEUR ESTIMÉE ET DU TGA — PAS DES BAUX RÉELS. TAXES APPROXIMÉES DEPUIS LE RÔLE. NE CONSTITUE PAS UN CONSEIL FINANCIER.",436      M, y, { width: CW3, characterSpacing: 0.3 });437    y += 22;438  }439  // méthodo440  const my = Math.max(legY + 20, ty + 14);441  card(doc, M, my, W - 2 * M, 112, { fill: INK, shadow: false });442  doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("MÉTHODOLOGIE", M + 16, my + 12, { characterSpacing: 1.4 });443  doc.font("Inter").fontSize(7.8).fillColor("#e8e6df").text(444    "L'estimation combine (1) un modèle hédonique spécialisé plex (gradient boosting en ratio prix/rôle, insensible à l'échelle) entraîné sur ~91 000 ventes de plex québécois " +445    "(2021-2026) appariées au rôle d'évaluation foncière géoréférencé du MAMH — erreur médiane de 14 % — et (2) une médiane " +446    "pondérée des ventes comparables voisines, chacune ajustée pour l'évolution du marché (indice mensuel), l'écart de " +447    "superficie (50 % du $/m²) et l'écart d'âge (0,5 %/an, plafonné à ±10 %) et l'écart de portes (50 % du prix par porte, plafonné à ±30 %). La fourchette correspond aux 10e et 90e " +448    "centiles du modèle, calibrés par méthode conforme (couverture 80 % vérifiée), recentrés sur l'estimation finale. L'indice de confiance (A-D) reflète le nombre de comparables, " +449    "leur dispersion et la largeur de la fourchette. Validation selon la norme IAAO sur études de ratios.",450    M + 16, my + 26, { width: W - 2 * M - 32, lineGap: 1.5 });451452  doc.end();453  return done;454}455