// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Moteur de la méthode du coût — calcul PUR et DÉTERMINISTE : * même entrée + même contexte de prix = même résultat, aucune IA. * * matériau_i = Q × qté_i × (1 + pertes_i) × prix_i × FacteurMatériau * main-d'œuvre = Q × heures_i × taux_métier_i × FacteurMainDœuvre * équipement = Q × équip_i × FacteurÉquipement * Direct = Σ lignes ; Indirects = Direct × Σ pct * FG = (Direct + Indirects) × pct ; Profit = (Direct + Indirects + FG) × pct * Contingence = (Direct + Indirects) × pct * RCN = Direct + Indirects + FG + Profit + Contingence * V_coût = V_terrain + RCN − (D_phys + D_fonct + D_ext) */ import type { Assembly, AssemblyUnitCost, BenchmarkCheck, CategoryTotal, Condition, ConfidenceBreakdown, CostEstimate, CostInput, CostItem, CostLocation, DepreciationComponentRow, DepreciationResult, EstimateLine, ItemPrice, LabourRate, Provenance, QuantityLine, UxCategory, } from "./types"; import { BUILDING_TYPES, CONDITION_GROUPS, INDIRECT_LABELS, METHOD_VERSION, UX_CATEGORIES, categoryLabel } from "./taxonomy"; export interface BenchmarkRow { source: string; buildingType: string; // single_family | townhouse | plex | condo | cottage market: string; unit: string; low: number; high: number; midpoint: number | null; year: number; notes: string | null; } export interface ConditionRule { effectiveAgeRatio: number; economicLife: number; } /** Contexte de prix : tout ce dont le moteur a besoin, lu une seule fois (instantané). */ export interface PricingContext { asOf: string; items: Map; prices: Map; labour: Map; // par code de métier (compagnon, résidentiel léger) assemblies: Map; location: CostLocation; conditionRules: Map>; benchmarks: BenchmarkRow[]; /** taux horaire de repli (hypothèse) si un métier manque */ fallbackHourlyRate: number; } export const FALLBACK_HOURLY_RATE = 85; // $/h coût employeur — hypothèse si aucune grille const Z90 = 1.2816; const MAX_DEPRECIATION_PCT = 0.9; const r2 = (x: number) => Math.round(x * 100) / 100; const r0 = (x: number) => Math.round(x); /* ------------------------------------------------------ coût d'assemblage */ function priceSigmaPct(p: ItemPrice | undefined): number { if (!p) return 0.2; if (p.sourceCount >= 2 && p.price > 0) return Math.max(0.04, (p.high - p.low) / (2 * Z90 * p.price)); switch (p.kind) { case "observed": case "official": return 0.08; case "indexed": return 0.1; case "derived": return 0.1; case "reference": return 0.15; default: return 0.2; } } export function assemblyUnitCost(asm: Assembly, ctx: PricingContext): AssemblyUnitCost { let material = 0, labour = 0, equipment = 0, varSum = 0, observedMaterial = 0; let freshest: string | null = null; const components: AssemblyUnitCost["components"] = []; for (const c of asm.components) { const item = c.itemCode ? ctx.items.get(c.itemCode) : undefined; const price = c.itemCode ? ctx.prices.get(c.itemCode) : undefined; const unitPrice = price?.price ?? 0; const isEquipItem = item?.materialClass === "equipment" || item?.materialClass === "rental"; const qty = c.quantity * (1 + c.wasteFactor); const matCost = item && !isEquipItem ? qty * unitPrice : 0; const eqItem = item && isEquipItem ? qty * unitPrice : 0; const trade = c.trade ?? item?.trade ?? null; const rate = trade ? ctx.labour.get(trade) : undefined; const hourly = c.labourHours > 0 ? (rate?.totalEmployerCost ?? ctx.fallbackHourlyRate) : null; const labCost = c.labourHours * (hourly ?? 0); const eqCost = c.equipmentCost + eqItem; material += matCost; labour += labCost; equipment += eqCost; if (price && (price.kind === "observed" || price.kind === "official" || price.kind === "indexed")) observedMaterial += matCost; if (price?.observedAt && (!freshest || price.observedAt > freshest)) freshest = price.observedAt; const sp = priceSigmaPct(price); varSum += (matCost * sp) ** 2 + (labCost * 0.15) ** 2 + (eqCost * 0.2) ** 2; const prov: Provenance = price ? { 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 } : { kind: "assumption", source: c.itemCode ? "aucun prix" : "Vrai-Prix — hypothèse de productivité", confidence: c.itemCode ? 0 : 40, note: c.notes }; const rateProv: Provenance | null = c.labourHours > 0 ? rate ? { kind: "official", source: rate.source, sourceUrl: rate.sourceUrl, effectiveDate: rate.effectiveFrom, confidence: rate.confidence, note: `${rate.tradeNameFr} — ${rate.classification}, ${rate.sector}` } : { kind: "assumption", source: "Vrai-Prix — taux horaire de repli", confidence: 30, note: `Aucune grille pour le métier « ${trade ?? "?"} » : ${ctx.fallbackHourlyRate} $/h (hypothèse)` } : null; components.push({ itemCode: c.itemCode, nameFr: item?.nameFr ?? (c.labourHours ? "Main-d'œuvre" : "Équipement"), nameEn: item?.nameEn ?? (c.labourHours ? "Labour" : "Equipment"), unit: item?.unit ?? "h", quantity: c.quantity, wasteFactor: c.wasteFactor, unitPrice: r2(unitPrice), materialCost: r2(matCost), labourHours: c.labourHours, trade, hourlyRate: hourly, labourCost: r2(labCost), equipmentCost: r2(eqCost), provenance: prov, rateProvenance: rateProv, }); } const direct = material + labour + equipment; return { code: asm.code, unit: asm.unit, material: r2(material), labour: r2(labour), equipment: r2(equipment), direct: r2(direct), sigmaPct: direct > 0 ? Math.sqrt(varSum) / direct : 0.2, observedShare: material > 0 ? observedMaterial / material : 0, components, freshestObservation: freshest, }; } /* ------------------------------------------------------------- estimation */ const QTY_UNCERTAINTY: Record = { derived: 0.1, AI: 0.15, user: 0.03, listing: 0.05, MAMH: 0.03, cadastral: 0.02, assumed: 0.2 }; export function computeEstimate(input: CostInput, quantities: QuantityLine[], ctx: PricingContext, meta: { id: string; createdAt: string; costDatabaseVersion: string; assemblyVersion: string }): CostEstimate { const loc = ctx.location; const warnings: string[] = []; const lines: EstimateLine[] = []; let unpriced = 0; for (const q of quantities) { const asm = ctx.assemblies.get(q.assemblyCode); if (!asm) { warnings.push(`assemblage inconnu : ${q.assemblyCode}`); continue; } const u = assemblyUnitCost(asm, ctx); if (u.direct <= 0) unpriced++; const material = u.material * q.quantity; const labour = u.labour * q.quantity; const equipment = u.equipment * q.quantity; const direct = material + labour + equipment; const adjusted = material * loc.materialFactor + labour * loc.labourFactor + equipment * loc.equipmentFactor; const qUnc = QTY_UNCERTAINTY[q.source] ?? 0.1; const sigma = Math.sqrt((adjusted * u.sigmaPct) ** 2 + (adjusted * qUnc) ** 2); const conf = Math.round(100 * (0.6 * u.observedShare + 0.4 * (1 - Math.min(1, u.sigmaPct / 0.3)))); lines.push({ assemblyCode: asm.code, nameFr: asm.nameFr, nameEn: asm.nameEn, category: asm.category, quantity: q.quantity, unit: asm.unit, quantitySource: q.source, quantityFormula: q.formula, unitCost: u.direct, material: r0(material), labour: r0(labour), equipment: r0(equipment), direct: r0(direct), locationAdjustment: r0(adjusted - direct), adjusted: r0(adjusted), sigma: r0(sigma), observedShare: u.observedShare, confidence: conf, economicLife: asm.economicLife, conditionGroup: asm.conditionGroup, unitDetail: u, }); } 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)); const directMaterial = r0(lines.reduce((s, l) => s + l.material * loc.materialFactor, 0)); const directLabour = r0(lines.reduce((s, l) => s + l.labour * loc.labourFactor, 0)); const directEquipment = r0(lines.reduce((s, l) => s + l.equipment * loc.equipmentFactor, 0)); // le direct est la somme exacte des trois composantes (les lignes sont arrondies individuellement) const directCost = directMaterial + directLabour + directEquipment; const categories: CategoryTotal[] = UX_CATEGORIES.filter((c) => lines.some((l) => l.category === c.key)).map((c) => { const ls = lines.filter((l) => l.category === c.key); const adjusted = ls.reduce((s, l) => s + l.adjusted, 0); return { category: c.key, labelFr: c.fr, labelEn: c.en, 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)), direct: r0(ls.reduce((s, l) => s + l.direct, 0)), adjusted: r0(adjusted), sharePct: directCost > 0 ? r2((100 * adjusted) / directCost) : 0, }; }); const p = input.params; 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) })); const indirectCost = indirect.reduce((s, i) => s + i.amount, 0); const contractorOverhead = r0(((directCost + indirectCost) * p.overheadPct) / 100); const contractorProfit = r0(((directCost + indirectCost + contractorOverhead) * p.profitPct) / 100); const contingency = r0(((directCost + indirectCost) * p.contingencyPct) / 100); const replacementCostNew = r0(directCost + indirectCost + contractorOverhead + contractorProfit + contingency); // fourchette : composante aléatoire (racine des carrés des lignes) + systématique (localisation 3 %, modèle d'assemblages 5 %) const randomSigma = Math.sqrt(lines.reduce((s, l) => s + l.sigma ** 2, 0)); const locUnc = loc.confidence >= 75 ? 0.02 : 0.04; const systematicSigma = directCost * Math.sqrt(locUnc ** 2 + 0.05 ** 2); const sigmaDirect = Math.sqrt(randomSigma ** 2 + systematicSigma ** 2); const scale = directCost > 0 ? replacementCostNew / directCost : 1; const sigma = sigmaDirect * scale; 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) }; const gfa = Math.max(1, input.building.grossFloorAreaSqft); const perSqft = r2(replacementCostNew / gfa); const perM2 = r2(replacementCostNew / (gfa * 0.092903)); const depreciation = computeDepreciation(input, lines, replacementCostNew, directCost, ctx); const landValue = input.land.value ?? 0; if (input.land.value == null) warnings.push("land_missing"); const costApproachValue = r0(landValue + depreciation.depreciatedImprovementValue); const benchmarks = checkBenchmarks(input, ctx, perSqft); const materialObservedShare = directMaterial > 0 ? lines.reduce((s, l) => s + l.material * loc.materialFactor * l.observedShare, 0) / directMaterial : 0; const coverage = { materialObservedShare: r2(materialObservedShare), assembliesPriced: lines.length - unpriced, assembliesTotal: lines.length, pricingCoverage: lines.length ? r2((lines.length - unpriced) / lines.length) : 0 }; const confidence = computeConfidence(input, lines, ctx, benchmarks, coverage, directLabour, directCost); if (unpriced) warnings.push(`${unpriced} assemblage(s) sans prix`); return { id: meta.id, createdAt: meta.createdAt, methodVersion: METHOD_VERSION, costDatabaseVersion: meta.costDatabaseVersion, assemblyVersion: meta.assemblyVersion, priceDate: ctx.asOf, input, location: loc, quantities, lines, categories, directCost: r0(directCost), directMaterial: r0(directMaterial), directLabour: r0(directLabour), directEquipment: r0(directEquipment), indirect, indirectCost: r0(indirectCost), contractorOverhead, contractorProfit, contingency, replacementCostNew, range, perSqft, perM2, depreciation, landValue: r0(landValue), costApproachValue, confidence, benchmarks, coverage, otherReadings: null, warnings, }; } /* ----------------------------------------------------------- dépréciation */ export function computeDepreciation(input: CostInput, lines: EstimateLine[], rcn: number, directCost: number, ctx: PricingContext): DepreciationResult { const d = input.depreciation; const year = Number(ctx.asOf.slice(0, 4)); const chronologicalAge = input.building.yearBuilt && input.building.yearBuilt > 1600 ? Math.max(0, year - input.building.yearBuilt) : null; const economicLife = d.economicLife || BUILDING_TYPES.find((t) => t.key === input.building.type)?.economicLife || 60; const scale = directCost > 0 ? rcn / directCost : 1; let physical = 0; let physicalPct = 0; let effectiveAge: number | null = d.effectiveAge ?? chronologicalAge; const components: DepreciationComponentRow[] = []; if (d.method === "components") { for (const g of CONDITION_GROUPS) { const ls = lines.filter((l) => l.conditionGroup === g.key); if (!ls.length) continue; const groupRcn = ls.reduce((s, l) => s + l.adjusted, 0) * scale; 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)); const life = Math.max(5, Math.round(lifeW)); const cond = d.componentConditions[g.key] ?? null; const rule = cond ? ctx.conditionRules.get(g.key)?.get(cond) : undefined; let effAge: number; let ruleTxt: string; if (cond && rule) { effAge = rule.effectiveAgeRatio * life; ruleTxt = `condition « ${cond} » → âge effectif = ${rule.effectiveAgeRatio} × vie ${life} ans`; } else if (d.effectiveAge != null && g.key === "structure") { effAge = Math.min(d.effectiveAge, life); ruleTxt = `âge effectif saisi (${d.effectiveAge} ans), plafonné à la vie de la composante`; } else { effAge = Math.min(chronologicalAge ?? 0, life); ruleTxt = chronologicalAge != null ? `âge chronologique (${chronologicalAge} ans) plafonné à la vie ${life} ans — aucune condition saisie` : "âge inconnu → 0"; } const pct = Math.min(MAX_DEPRECIATION_PCT, life > 0 ? effAge / life : 0); const dep = groupRcn * pct; physical += dep; 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 }); } physicalPct = rcn > 0 ? physical / rcn : 0; effectiveAge = r2(physicalPct * economicLife); } else { const ea = effectiveAge ?? 0; physicalPct = Math.min(MAX_DEPRECIATION_PCT, economicLife > 0 ? ea / economicLife : 0); physical = rcn * physicalPct; } const functionalCurable = d.functional.filter((f) => f.curable).reduce((s, f) => s + (f.costToCure || 0), 0); const functionalIncurable = d.functional.filter((f) => !f.curable).reduce((s, f) => s + (f.valueLoss || 0), 0); const functional = functionalCurable + functionalIncurable; const external = Math.max(0, d.externalValueLoss || 0); const total = Math.min(rcn, physical + functional + external); return { method: d.method, chronologicalAge, effectiveAge, economicLife, physicalPct: r2(physicalPct * 100), physical: r0(physical), components, functionalCurable: r0(functionalCurable), functionalIncurable: r0(functionalIncurable), functional: r0(functional), external: r0(external), total: r0(total), depreciatedImprovementValue: r0(Math.max(0, rcn - total)), }; } /* -------------------------------------------------------------- confiance */ function computeConfidence(input: CostInput, lines: EstimateLine[], ctx: PricingContext, benchmarks: BenchmarkCheck[], coverage: CostEstimate["coverage"], directLabour: number, directCost: number): ConfidenceBreakdown { const notesFr: string[] = []; const notesEn: string[] = []; // fraîcheur : âge moyen pondéré des observations let wAge = 0, wSum = 0; for (const l of lines) for (const c of l.unitDetail.components) { if (c.provenance.observedAt && (c.provenance.kind === "observed" || c.provenance.kind === "official")) { const age = (new Date(ctx.asOf).getTime() - new Date(c.provenance.observedAt).getTime()) / 86400000; const w = c.materialCost * l.quantity; wAge += age * w; wSum += w; } } const freshness = wSum > 0 ? Math.round(20 * Math.max(0, 1 - wAge / wSum / 180)) : 4; 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."); } const cov = Math.round(20 * (0.7 * coverage.materialObservedShare + 0.3 * coverage.pricingCoverage)); 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).`); } const location = ctx.location.confidence >= 75 ? 15 : ctx.location.confidence >= 55 ? 11 : 8; // main-d'œuvre : part des $ de main-d'œuvre appuyée sur une grille officielle let offLab = 0; 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; const labour = directLabour > 0 ? Math.round(15 * Math.min(1, offLab / directLabour)) : 8; 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)."); } const withBench = benchmarks.filter((b) => b.status !== "unavailable"); const bench = !withBench.length ? 5 : withBench.some((b) => b.status === "within") ? 10 : 3; if (!withBench.length) { notesFr.push("Aucun benchmark externe disponible pour ce type/marché."); notesEn.push("No external benchmark available for this type/market."); } 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."); } const keys = ["grossFloorAreaSqft", "stories", "yearBuilt", "basement", "garage", "siding", "roof", "heating", "bathrooms", "kitchenQuality"]; const known = keys.filter((k) => { const s = input.attributeSources[k]; return s && s !== "assumed" && s !== "derived"; }).length; const building = Math.round(20 * (known / keys.length)); 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.`); } const total = Math.max(0, Math.min(100, freshness + cov + location + labour + bench + building)); const letter = total >= 80 ? "A" : total >= 65 ? "B" : total >= 50 ? "C" : "D"; void directCost; return { freshness, coverage: cov, location, labour, benchmarks: bench, building, total, letter, notesFr, notesEn }; } /* ------------------------------------------------------------- benchmarks */ export function benchmarkTypeFor(t: CostInput["building"]["type"]): string { switch (t) { case "detached": case "chalet": return "single_family"; case "semi_detached": case "row": return "townhouse"; case "plex": return "plex"; case "condo": return "condo"; default: return "single_family"; } } export function checkBenchmarks(input: CostInput, ctx: PricingContext, perSqft: number): BenchmarkCheck[] { const bt = benchmarkTypeFor(input.building.type); const market = ctx.location.code === "QC-QUE" || ctx.location.code === "QC-LEV" ? "Québec" : ctx.location.code === "QC-GAT" ? "Ottawa-Gatineau" : "Montréal"; const rows = ctx.benchmarks.filter((b) => b.buildingType === bt); const chosen = rows.filter((b) => b.market === market); const use = chosen.length ? chosen : rows.filter((b) => b.market === "Montréal"); return use.map((b) => { const within = perSqft >= b.low && perSqft <= b.high; const status: BenchmarkCheck["status"] = within ? "within" : perSqft < b.low ? "below" : "above"; const ref = within ? null : perSqft < b.low ? b.low : b.high; 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 }; }); } /** Libellé de catégorie (réexport pratique). */ export const catLabel = (k: UxCategory, fr: boolean) => categoryLabel(k, fr);