Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Moteur de la méthode du coût — calcul PUR et DÉTERMINISTE :4 * même entrée + même contexte de prix = même résultat, aucune IA.5 *6 * matériau_i = Q × qté_i × (1 + pertes_i) × prix_i × FacteurMatériau7 * main-d'œuvre = Q × heures_i × taux_métier_i × FacteurMainDœuvre8 * équipement = Q × équip_i × FacteurÉquipement9 * Direct = Σ lignes ; Indirects = Direct × Σ pct10 * FG = (Direct + Indirects) × pct ; Profit = (Direct + Indirects + FG) × pct11 * Contingence = (Direct + Indirects) × pct12 * RCN = Direct + Indirects + FG + Profit + Contingence13 * V_coût = V_terrain + RCN − (D_phys + D_fonct + D_ext)14 */15import type {16 Assembly, AssemblyUnitCost, BenchmarkCheck, CategoryTotal, Condition, ConfidenceBreakdown, CostEstimate, CostInput, CostItem,17 CostLocation, DepreciationComponentRow, DepreciationResult, EstimateLine, ItemPrice, LabourRate, Provenance, QuantityLine, UxCategory,18} from "./types";19import { BUILDING_TYPES, CONDITION_GROUPS, INDIRECT_LABELS, METHOD_VERSION, UX_CATEGORIES, categoryLabel } from "./taxonomy";2021export interface BenchmarkRow {22 source: string;23 buildingType: string; // single_family | townhouse | plex | condo | cottage24 market: string;25 unit: string;26 low: number;27 high: number;28 midpoint: number | null;29 year: number;30 notes: string | null;31}3233export interface ConditionRule {34 effectiveAgeRatio: number;35 economicLife: number;36}3738/** Contexte de prix : tout ce dont le moteur a besoin, lu une seule fois (instantané). */39export interface PricingContext {40 asOf: string;41 items: Map<string, CostItem>;42 prices: Map<string, ItemPrice>;43 labour: Map<string, LabourRate>; // par code de métier (compagnon, résidentiel léger)44 assemblies: Map<string, Assembly>;45 location: CostLocation;46 conditionRules: Map<string, Map<Condition, ConditionRule>>;47 benchmarks: BenchmarkRow[];48 /** taux horaire de repli (hypothèse) si un métier manque */49 fallbackHourlyRate: number;50}5152export const FALLBACK_HOURLY_RATE = 85; // $/h coût employeur — hypothèse si aucune grille53const Z90 = 1.2816;54const MAX_DEPRECIATION_PCT = 0.9;5556const r2 = (x: number) => Math.round(x * 100) / 100;57const r0 = (x: number) => Math.round(x);5859/* ------------------------------------------------------ coût d'assemblage */6061function priceSigmaPct(p: ItemPrice | undefined): number {62 if (!p) return 0.2;63 if (p.sourceCount >= 2 && p.price > 0) return Math.max(0.04, (p.high - p.low) / (2 * Z90 * p.price));64 switch (p.kind) {65 case "observed": case "official": return 0.08;66 case "indexed": return 0.1;67 case "derived": return 0.1;68 case "reference": return 0.15;69 default: return 0.2;70 }71}7273export function assemblyUnitCost(asm: Assembly, ctx: PricingContext): AssemblyUnitCost {74 let material = 0, labour = 0, equipment = 0, varSum = 0, observedMaterial = 0;75 let freshest: string | null = null;76 const components: AssemblyUnitCost["components"] = [];77 for (const c of asm.components) {78 const item = c.itemCode ? ctx.items.get(c.itemCode) : undefined;79 const price = c.itemCode ? ctx.prices.get(c.itemCode) : undefined;80 const unitPrice = price?.price ?? 0;81 const isEquipItem = item?.materialClass === "equipment" || item?.materialClass === "rental";82 const qty = c.quantity * (1 + c.wasteFactor);83 const matCost = item && !isEquipItem ? qty * unitPrice : 0;84 const eqItem = item && isEquipItem ? qty * unitPrice : 0;85 const trade = c.trade ?? item?.trade ?? null;86 const rate = trade ? ctx.labour.get(trade) : undefined;87 const hourly = c.labourHours > 0 ? (rate?.totalEmployerCost ?? ctx.fallbackHourlyRate) : null;88 const labCost = c.labourHours * (hourly ?? 0);89 const eqCost = c.equipmentCost + eqItem;90 material += matCost;91 labour += labCost;92 equipment += eqCost;93 if (price && (price.kind === "observed" || price.kind === "official" || price.kind === "indexed")) observedMaterial += matCost;94 if (price?.observedAt && (!freshest || price.observedAt > freshest)) freshest = price.observedAt;95 const sp = priceSigmaPct(price);96 varSum += (matCost * sp) ** 2 + (labCost * 0.15) ** 2 + (eqCost * 0.2) ** 2;97 const prov: Provenance = price98 ? { kind: price.kind, source: price.sources.map((s) => s.source).join(", ") || "—", sourceUrl: price.sources[0]?.url ?? null, observedAt: price.observedAt, confidence: price.confidence, note: price.note }99 : { kind: "assumption", source: c.itemCode ? "aucun prix" : "Vrai-Prix — hypothèse de productivité", confidence: c.itemCode ? 0 : 40, note: c.notes };100 const rateProv: Provenance | null = c.labourHours > 0101 ? rate102 ? { kind: "official", source: rate.source, sourceUrl: rate.sourceUrl, effectiveDate: rate.effectiveFrom, confidence: rate.confidence, note: `${rate.tradeNameFr} — ${rate.classification}, ${rate.sector}` }103 : { kind: "assumption", source: "Vrai-Prix — taux horaire de repli", confidence: 30, note: `Aucune grille pour le métier « ${trade ?? "?"} » : ${ctx.fallbackHourlyRate} $/h (hypothèse)` }104 : null;105 components.push({106 itemCode: c.itemCode, nameFr: item?.nameFr ?? (c.labourHours ? "Main-d'œuvre" : "Équipement"), nameEn: item?.nameEn ?? (c.labourHours ? "Labour" : "Equipment"), unit: item?.unit ?? "h",107 quantity: c.quantity, wasteFactor: c.wasteFactor, unitPrice: r2(unitPrice), materialCost: r2(matCost), labourHours: c.labourHours, trade, hourlyRate: hourly, labourCost: r2(labCost), equipmentCost: r2(eqCost),108 provenance: prov, rateProvenance: rateProv,109 });110 }111 const direct = material + labour + equipment;112 return {113 code: asm.code, unit: asm.unit, material: r2(material), labour: r2(labour), equipment: r2(equipment), direct: r2(direct),114 sigmaPct: direct > 0 ? Math.sqrt(varSum) / direct : 0.2, observedShare: material > 0 ? observedMaterial / material : 0, components, freshestObservation: freshest,115 };116}117118/* ------------------------------------------------------------- estimation */119120const QTY_UNCERTAINTY: Record<string, number> = { derived: 0.1, AI: 0.15, user: 0.03, listing: 0.05, MAMH: 0.03, cadastral: 0.02, assumed: 0.2 };121122export function computeEstimate(input: CostInput, quantities: QuantityLine[], ctx: PricingContext, meta: { id: string; createdAt: string; costDatabaseVersion: string; assemblyVersion: string }): CostEstimate {123 const loc = ctx.location;124 const warnings: string[] = [];125 const lines: EstimateLine[] = [];126 let unpriced = 0;127128 for (const q of quantities) {129 const asm = ctx.assemblies.get(q.assemblyCode);130 if (!asm) { warnings.push(`assemblage inconnu : ${q.assemblyCode}`); continue; }131 const u = assemblyUnitCost(asm, ctx);132 if (u.direct <= 0) unpriced++;133 const material = u.material * q.quantity;134 const labour = u.labour * q.quantity;135 const equipment = u.equipment * q.quantity;136 const direct = material + labour + equipment;137 const adjusted = material * loc.materialFactor + labour * loc.labourFactor + equipment * loc.equipmentFactor;138 const qUnc = QTY_UNCERTAINTY[q.source] ?? 0.1;139 const sigma = Math.sqrt((adjusted * u.sigmaPct) ** 2 + (adjusted * qUnc) ** 2);140 const conf = Math.round(100 * (0.6 * u.observedShare + 0.4 * (1 - Math.min(1, u.sigmaPct / 0.3))));141 lines.push({142 assemblyCode: asm.code, nameFr: asm.nameFr, nameEn: asm.nameEn, category: asm.category, quantity: q.quantity, unit: asm.unit, quantitySource: q.source, quantityFormula: q.formula,143 unitCost: u.direct, material: r0(material), labour: r0(labour), equipment: r0(equipment), direct: r0(direct), locationAdjustment: r0(adjusted - direct), adjusted: r0(adjusted),144 sigma: r0(sigma), observedShare: u.observedShare, confidence: conf, economicLife: asm.economicLife, conditionGroup: asm.conditionGroup, unitDetail: u,145 });146 }147 lines.sort((a, b) => (UX_CATEGORIES.find((c) => c.key === a.category)?.order ?? 99) - (UX_CATEGORIES.find((c) => c.key === b.category)?.order ?? 99) || a.nameFr.localeCompare(b.nameFr));148149 const directMaterial = r0(lines.reduce((s, l) => s + l.material * loc.materialFactor, 0));150 const directLabour = r0(lines.reduce((s, l) => s + l.labour * loc.labourFactor, 0));151 const directEquipment = r0(lines.reduce((s, l) => s + l.equipment * loc.equipmentFactor, 0));152 // le direct est la somme exacte des trois composantes (les lignes sont arrondies individuellement)153 const directCost = directMaterial + directLabour + directEquipment;154155 const categories: CategoryTotal[] = UX_CATEGORIES.filter((c) => lines.some((l) => l.category === c.key)).map((c) => {156 const ls = lines.filter((l) => l.category === c.key);157 const adjusted = ls.reduce((s, l) => s + l.adjusted, 0);158 return {159 category: c.key, labelFr: c.fr, labelEn: c.en,160 material: r0(ls.reduce((s, l) => s + l.material * loc.materialFactor, 0)), labour: r0(ls.reduce((s, l) => s + l.labour * loc.labourFactor, 0)), equipment: r0(ls.reduce((s, l) => s + l.equipment * loc.equipmentFactor, 0)),161 direct: r0(ls.reduce((s, l) => s + l.direct, 0)), adjusted: r0(adjusted), sharePct: directCost > 0 ? r2((100 * adjusted) / directCost) : 0,162 };163 });164165 const p = input.params;166 const indirect = (Object.keys(INDIRECT_LABELS) as (keyof typeof INDIRECT_LABELS)[]).map((key) => ({ key, labelFr: INDIRECT_LABELS[key].fr, labelEn: INDIRECT_LABELS[key].en, pct: p.indirect[key], amount: r0((directCost * p.indirect[key]) / 100) }));167 const indirectCost = indirect.reduce((s, i) => s + i.amount, 0);168 const contractorOverhead = r0(((directCost + indirectCost) * p.overheadPct) / 100);169 const contractorProfit = r0(((directCost + indirectCost + contractorOverhead) * p.profitPct) / 100);170 const contingency = r0(((directCost + indirectCost) * p.contingencyPct) / 100);171 const replacementCostNew = r0(directCost + indirectCost + contractorOverhead + contractorProfit + contingency);172173 // fourchette : composante aléatoire (racine des carrés des lignes) + systématique (localisation 3 %, modèle d'assemblages 5 %)174 const randomSigma = Math.sqrt(lines.reduce((s, l) => s + l.sigma ** 2, 0));175 const locUnc = loc.confidence >= 75 ? 0.02 : 0.04;176 const systematicSigma = directCost * Math.sqrt(locUnc ** 2 + 0.05 ** 2);177 const sigmaDirect = Math.sqrt(randomSigma ** 2 + systematicSigma ** 2);178 const scale = directCost > 0 ? replacementCostNew / directCost : 1;179 const sigma = sigmaDirect * scale;180 const range = { p10: r0(replacementCostNew - Z90 * sigma), p90: r0(replacementCostNew + Z90 * sigma), sigma: r0(sigma), low: r0(replacementCostNew - Z90 * sigma), high: r0(replacementCostNew + Z90 * sigma) };181182 const gfa = Math.max(1, input.building.grossFloorAreaSqft);183 const perSqft = r2(replacementCostNew / gfa);184 const perM2 = r2(replacementCostNew / (gfa * 0.092903));185186 const depreciation = computeDepreciation(input, lines, replacementCostNew, directCost, ctx);187 const landValue = input.land.value ?? 0;188 if (input.land.value == null) warnings.push("land_missing");189 const costApproachValue = r0(landValue + depreciation.depreciatedImprovementValue);190191 const benchmarks = checkBenchmarks(input, ctx, perSqft);192 const materialObservedShare = directMaterial > 0 ? lines.reduce((s, l) => s + l.material * loc.materialFactor * l.observedShare, 0) / directMaterial : 0;193 const coverage = { materialObservedShare: r2(materialObservedShare), assembliesPriced: lines.length - unpriced, assembliesTotal: lines.length, pricingCoverage: lines.length ? r2((lines.length - unpriced) / lines.length) : 0 };194 const confidence = computeConfidence(input, lines, ctx, benchmarks, coverage, directLabour, directCost);195 if (unpriced) warnings.push(`${unpriced} assemblage(s) sans prix`);196197 return {198 id: meta.id, createdAt: meta.createdAt, methodVersion: METHOD_VERSION, costDatabaseVersion: meta.costDatabaseVersion, assemblyVersion: meta.assemblyVersion, priceDate: ctx.asOf,199 input, location: loc, quantities, lines, categories,200 directCost: r0(directCost), directMaterial: r0(directMaterial), directLabour: r0(directLabour), directEquipment: r0(directEquipment),201 indirect, indirectCost: r0(indirectCost), contractorOverhead, contractorProfit, contingency, replacementCostNew, range, perSqft, perM2,202 depreciation, landValue: r0(landValue), costApproachValue, confidence, benchmarks, coverage, otherReadings: null, warnings,203 };204}205206/* ----------------------------------------------------------- dépréciation */207208export function computeDepreciation(input: CostInput, lines: EstimateLine[], rcn: number, directCost: number, ctx: PricingContext): DepreciationResult {209 const d = input.depreciation;210 const year = Number(ctx.asOf.slice(0, 4));211 const chronologicalAge = input.building.yearBuilt && input.building.yearBuilt > 1600 ? Math.max(0, year - input.building.yearBuilt) : null;212 const economicLife = d.economicLife || BUILDING_TYPES.find((t) => t.key === input.building.type)?.economicLife || 60;213 const scale = directCost > 0 ? rcn / directCost : 1;214 let physical = 0;215 let physicalPct = 0;216 let effectiveAge: number | null = d.effectiveAge ?? chronologicalAge;217 const components: DepreciationComponentRow[] = [];218219 if (d.method === "components") {220 for (const g of CONDITION_GROUPS) {221 const ls = lines.filter((l) => l.conditionGroup === g.key);222 if (!ls.length) continue;223 const groupRcn = ls.reduce((s, l) => s + l.adjusted, 0) * scale;224 const lifeW = ls.reduce((s, l) => s + (l.economicLife ?? g.economicLife) * l.adjusted, 0) / Math.max(1, ls.reduce((s, l) => s + l.adjusted, 0));225 const life = Math.max(5, Math.round(lifeW));226 const cond = d.componentConditions[g.key] ?? null;227 const rule = cond ? ctx.conditionRules.get(g.key)?.get(cond) : undefined;228 let effAge: number;229 let ruleTxt: string;230 if (cond && rule) {231 effAge = rule.effectiveAgeRatio * life;232 ruleTxt = `condition « ${cond} » → âge effectif = ${rule.effectiveAgeRatio} × vie ${life} ans`;233 } else if (d.effectiveAge != null && g.key === "structure") {234 effAge = Math.min(d.effectiveAge, life);235 ruleTxt = `âge effectif saisi (${d.effectiveAge} ans), plafonné à la vie de la composante`;236 } else {237 effAge = Math.min(chronologicalAge ?? 0, life);238 ruleTxt = chronologicalAge != null ? `âge chronologique (${chronologicalAge} ans) plafonné à la vie ${life} ans — aucune condition saisie` : "âge inconnu → 0";239 }240 const pct = Math.min(MAX_DEPRECIATION_PCT, life > 0 ? effAge / life : 0);241 const dep = groupRcn * pct;242 physical += dep;243 components.push({ conditionGroup: g.key, labelFr: g.fr, labelEn: g.en, rcn: r0(groupRcn), economicLife: life, condition: cond, effectiveAge: r2(effAge), depreciationPct: r2(pct * 100), depreciation: r0(dep), remaining: r0(groupRcn - dep), rule: ruleTxt });244 }245 physicalPct = rcn > 0 ? physical / rcn : 0;246 effectiveAge = r2(physicalPct * economicLife);247 } else {248 const ea = effectiveAge ?? 0;249 physicalPct = Math.min(MAX_DEPRECIATION_PCT, economicLife > 0 ? ea / economicLife : 0);250 physical = rcn * physicalPct;251 }252 const functionalCurable = d.functional.filter((f) => f.curable).reduce((s, f) => s + (f.costToCure || 0), 0);253 const functionalIncurable = d.functional.filter((f) => !f.curable).reduce((s, f) => s + (f.valueLoss || 0), 0);254 const functional = functionalCurable + functionalIncurable;255 const external = Math.max(0, d.externalValueLoss || 0);256 const total = Math.min(rcn, physical + functional + external);257 return {258 method: d.method, chronologicalAge, effectiveAge, economicLife, physicalPct: r2(physicalPct * 100), physical: r0(physical), components,259 functionalCurable: r0(functionalCurable), functionalIncurable: r0(functionalIncurable), functional: r0(functional), external: r0(external), total: r0(total),260 depreciatedImprovementValue: r0(Math.max(0, rcn - total)),261 };262}263264/* -------------------------------------------------------------- confiance */265266function computeConfidence(input: CostInput, lines: EstimateLine[], ctx: PricingContext, benchmarks: BenchmarkCheck[], coverage: CostEstimate["coverage"], directLabour: number, directCost: number): ConfidenceBreakdown {267 const notesFr: string[] = [];268 const notesEn: string[] = [];269 // fraîcheur : âge moyen pondéré des observations270 let wAge = 0, wSum = 0;271 for (const l of lines) for (const c of l.unitDetail.components) {272 if (c.provenance.observedAt && (c.provenance.kind === "observed" || c.provenance.kind === "official")) {273 const age = (new Date(ctx.asOf).getTime() - new Date(c.provenance.observedAt).getTime()) / 86400000;274 const w = c.materialCost * l.quantity;275 wAge += age * w; wSum += w;276 }277 }278 const freshness = wSum > 0 ? Math.round(20 * Math.max(0, 1 - wAge / wSum / 180)) : 4;279 if (wSum === 0) { notesFr.push("Aucun prix observé récent : base de référence seulement."); notesEn.push("No recent observed price: reference base only."); }280 const cov = Math.round(20 * (0.7 * coverage.materialObservedShare + 0.3 * coverage.pricingCoverage));281 if (coverage.materialObservedShare < 0.5) { notesFr.push(`${Math.round(coverage.materialObservedShare * 100)} % du coût des matériaux repose sur des prix observés ; le reste sur des prix de référence (hypothèses).`); notesEn.push(`${Math.round(coverage.materialObservedShare * 100)} % of material cost rests on observed prices; the rest on reference prices (assumptions).`); }282 const location = ctx.location.confidence >= 75 ? 15 : ctx.location.confidence >= 55 ? 11 : 8;283 // main-d'œuvre : part des $ de main-d'œuvre appuyée sur une grille officielle284 let offLab = 0;285 for (const l of lines) for (const c of l.unitDetail.components) if (c.rateProvenance?.kind === "official") offLab += c.labourCost * l.quantity * ctx.location.labourFactor;286 const labour = directLabour > 0 ? Math.round(15 * Math.min(1, offLab / directLabour)) : 8;287 if (directLabour > 0 && offLab / directLabour < 0.9) { notesFr.push("Certains métiers utilisent le taux horaire de repli (aucune grille APCHQ/CCQ)."); notesEn.push("Some trades use the fallback hourly rate (no APCHQ/CCQ grid)."); }288 const withBench = benchmarks.filter((b) => b.status !== "unavailable");289 const bench = !withBench.length ? 5 : withBench.some((b) => b.status === "within") ? 10 : 3;290 if (!withBench.length) { notesFr.push("Aucun benchmark externe disponible pour ce type/marché."); notesEn.push("No external benchmark available for this type/market."); }291 else if (!withBench.some((b) => b.status === "within")) { notesFr.push("Écart important par rapport au benchmark externe."); notesEn.push("Large deviation from the external benchmark."); }292 const keys = ["grossFloorAreaSqft", "stories", "yearBuilt", "basement", "garage", "siding", "roof", "heating", "bathrooms", "kitchenQuality"];293 const known = keys.filter((k) => { const s = input.attributeSources[k]; return s && s !== "assumed" && s !== "derived"; }).length;294 const building = Math.round(20 * (known / keys.length));295 if (known < 7) { notesFr.push(`${keys.length - known} caractéristiques du bâtiment sur ${keys.length} sont supposées par défaut.`); notesEn.push(`${keys.length - known} of ${keys.length} building attributes are default assumptions.`); }296 const total = Math.max(0, Math.min(100, freshness + cov + location + labour + bench + building));297 const letter = total >= 80 ? "A" : total >= 65 ? "B" : total >= 50 ? "C" : "D";298 void directCost;299 return { freshness, coverage: cov, location, labour, benchmarks: bench, building, total, letter, notesFr, notesEn };300}301302/* ------------------------------------------------------------- benchmarks */303304export function benchmarkTypeFor(t: CostInput["building"]["type"]): string {305 switch (t) {306 case "detached": case "chalet": return "single_family";307 case "semi_detached": case "row": return "townhouse";308 case "plex": return "plex";309 case "condo": return "condo";310 default: return "single_family";311 }312}313314export function checkBenchmarks(input: CostInput, ctx: PricingContext, perSqft: number): BenchmarkCheck[] {315 const bt = benchmarkTypeFor(input.building.type);316 const market = ctx.location.code === "QC-QUE" || ctx.location.code === "QC-LEV" ? "Québec" : ctx.location.code === "QC-GAT" ? "Ottawa-Gatineau" : "Montréal";317 const rows = ctx.benchmarks.filter((b) => b.buildingType === bt);318 const chosen = rows.filter((b) => b.market === market);319 const use = chosen.length ? chosen : rows.filter((b) => b.market === "Montréal");320 return use.map((b) => {321 const within = perSqft >= b.low && perSqft <= b.high;322 const status: BenchmarkCheck["status"] = within ? "within" : perSqft < b.low ? "below" : "above";323 const ref = within ? null : perSqft < b.low ? b.low : b.high;324 return { source: b.source, buildingType: b.buildingType, market: b.market, unit: b.unit, low: b.low, high: b.high, midpoint: b.midpoint, year: b.year, estimatePerUnit: perSqft, status, deviationPct: ref ? r2(((perSqft - ref) / ref) * 100) : 0, notes: b.notes ? `${b.notes}${b.market !== market ? " — marché de substitution (Montréal)" : ""}` : b.market !== market ? "marché de substitution (Montréal)" : null };325 });326}327328/** Libellé de catégorie (réexport pratique). */329export const catLabel = (k: UxCategory, fr: boolean) => categoryLabel(k, fr);330