Repository Public
1// UQO Éval — Métré : moteur d'estimation (lignes → direct → majorations ordonnées → actualisation → taxes). Module PUR.2/**3 * Ordre d'application (documenté dans docs/construction/COST_FORMULAS.md) :4 * 1. quantité_assemblage = quantité_métré × facteur d'ajustement explicite (conversion d'unités de même dimension seulement)5 * 2. coût direct de ligne = quantité × (matériau + main-d'œuvre + équipement + autres) — ou × prix installé6 * 3. facteur régional appliqué PAR COMPOSANTE (matériau, main-d'œuvre, équipement) → « direct localisé »7 * 4. conditions générales = direct localisé × % (base : direct)8 * 5. frais généraux = (direct + CG) × % (base : cumulatif)9 * 6. profit = (direct + CG + FG) × % (base : cumulatif)10 * 7. contingence = direct localisé × % (base : direct — ne majore pas le profit)11 * 8. actualisation = sous-total × (facteur d'indice − 1) (StatCan, facteur résolu AVANT le calcul)12 * 9. TPS 5 % et TVQ 9,975 % sur le sous-total avant taxes (non composées : la TVQ ne s'applique pas sur la TPS depuis 2013)13 * Tout est accumulé en cents entiers ; chaque étape est arrondie une fois.14 */15import type { EstimateLineInput, EstimateLineResult, EstimateParams, EstimateResult, Provenance } from "./types";16import { convertQuantity, dimensionOf } from "./units";17import { fromCents, lineCents, mulCents, pctOf, roundCentsTo, sumCents, toCents } from "./money";1819export const ESTIMATE_FORMULA_VERSION = "metre-estimate-1.0";20export const GST_RATE = 5;21export const QST_RATE = 9.975;2223export interface UnitQuote {24 unit: string;25 material: number;26 labour: number;27 equipment: number;28 other: number;29 installed: boolean;30 provenance: Provenance;31 warnings?: string[];32}3334export interface EngineContext {35 /** devis unitaire pour une ligne (catalogue, recette ou observation) ; null = non chiffrable */36 quote(line: EstimateLineInput): UnitQuote | null;37 location: EstimateResult["location"];38 catalogueVersion: string;39 now: string;40}4142const r2 = (c: number) => fromCents(c);4344export function computeEstimate(lines: EstimateLineInput[], params: EstimateParams, ctx: EngineContext): EstimateResult {45 const warnings: string[] = [];46 const results: EstimateLineResult[] = [];47 const loc = ctx.location;48 let dirC = 0, locC = 0, matC = 0, labC = 0, eqC = 0, othC = 0, obsW = 0, assW = 0, priced = 0, active = 0;4950 for (const l of [...lines].sort((a, b) => a.sortOrder - b.sortOrder)) {51 const lw: string[] = [];52 const takeoff = Number.isFinite(l.quantity) ? Math.max(0, l.quantity) : 0;53 const factor = Number.isFinite(l.adjustmentFactor) && l.adjustmentFactor > 0 ? l.adjustmentFactor : 1;54 if (factor !== 1) lw.push(`facteur d'ajustement explicite × ${factor}`);55 let qty = takeoff * factor;56 let quote: UnitQuote | null = null;57 if (l.basis === "assumption") {58 if (l.unitPrice == null || !(l.unitPrice >= 0)) lw.push("prix unitaire manquant");59 else quote = { unit: l.unit, material: l.installed ? 0 : l.unitPrice, labour: 0, equipment: 0, other: l.installed ? l.unitPrice : 0, installed: l.installed, provenance: { basis: "assumption", kind: "assumption", evidenceClass: "user_assumption", source: "hypothèse de l'utilisateur", sourceUrl: null, observedAt: null, note: l.assumptionNote ?? null, confidence: l.assumptionNote ? 40 : 25, observedShare: 0 } };60 } else {61 quote = ctx.quote(l);62 if (!quote) lw.push(l.assemblyCode ? `assemblage introuvable ou sans prix : ${l.assemblyCode}` : "aucun assemblage choisi");63 }64 let unit = l.unit;65 if (quote && quote.unit !== l.unit) {66 try { const cv = convertQuantity(qty, l.unit, quote.unit); qty = cv.value; unit = quote.unit; lw.push(`quantité convertie ${l.unit} → ${quote.unit} (× ${cv.factor.toFixed(6)})`); }67 catch { lw.push(`unités incompatibles : ligne en ${l.unit} (${dimensionOf(l.unit)}), assemblage en ${quote.unit} (${dimensionOf(quote.unit)}) — ligne non chiffrée`); quote = null; }68 }69 if (quote?.warnings?.length) lw.push(...quote.warnings);70 const uMat = quote?.material ?? 0, uLab = quote?.labour ?? 0, uEq = quote?.equipment ?? 0, uOth = quote?.other ?? 0;71 const mC = lineCents(qty, uMat), lC = lineCents(qty, uLab), eC = lineCents(qty, uEq), oC = lineCents(qty, uOth);72 const dC = sumCents([mC, lC, eC, oC]);73 // les prix RSMeans sont déjà localisés (ville RSMeans) : pas de facteur régional du module par-dessus74 const already = l.basis === "rsmeans"; const fM = already ? 1 : loc.materialFactor, fL = already ? 1 : loc.labourFactor, fE = already ? 1 : loc.equipmentFactor;75 if (already && (loc.materialFactor !== 1 || loc.labourFactor !== 1 || loc.equipmentFactor !== 1)) lw.push("prix RSMeans déjà localisé : facteur régional non appliqué");76 const locatedC = sumCents([mulCents(mC, fM), mulCents(lC, fL), mulCents(eC, fE), oC]);77 const prov: Provenance = quote?.provenance ?? { basis: l.basis, kind: "assumption", evidenceClass: null, source: "non chiffré", sourceUrl: null, observedAt: null, note: null, confidence: 0, observedShare: 0 };78 const res: EstimateLineResult = {79 id: l.id, wbs: l.wbs, description: l.description, assemblyCode: l.assemblyCode, measurementId: l.measurementId, takeoffQuantity: takeoff, adjustmentFactor: factor, quantity: Math.round(qty * 10000) / 10000, unit,80 basis: l.basis, installed: quote?.installed ?? l.installed, unitMaterial: uMat, unitLabour: uLab, unitEquipment: uEq, unitOther: uOth, unitDirect: Math.round((uMat + uLab + uEq + uOth) * 100) / 100,81 material: r2(mC), labour: r2(lC), equipment: r2(eC), other: r2(oC), direct: r2(dC), located: r2(locatedC), provenance: prov, warnings: lw, excluded: l.excluded,82 };83 results.push(res);84 if (l.excluded) continue;85 active++;86 if (quote) priced++;87 dirC += dC; locC += locatedC; matC += mulCents(mC, fM); labC += mulCents(lC, fL); eqC += mulCents(eC, fE); othC += oC;88 obsW += locatedC * prov.observedShare; if (prov.basis === "assumption") assW += locatedC;89 }9091 const gcC = pctOf(locC, params.generalConditionsPct);92 const ohC = pctOf(locC + gcC, params.overheadPct);93 const prC = pctOf(locC + gcC + ohC, params.profitPct);94 const ctC = pctOf(locC, params.contingencyPct);95 const sub1 = sumCents([locC, gcC, ohC, prC, ctC]);96 const f = params.escalationFactor != null && Number.isFinite(params.escalationFactor) && params.escalationFactor > 0 ? params.escalationFactor : 1;97 if (params.escalateTo && params.escalationFactor == null) warnings.push("actualisation demandée mais aucun facteur d'indice disponible : aucune actualisation appliquée");98 const escC = mulCents(sub1, f - 1);99 const sub2 = sub1 + escC;100 const gstC = params.applyGst ? pctOf(sub2, GST_RATE) : 0;101 const qstC = params.applyQst ? pctOf(sub2, QST_RATE) : 0;102 const step = Math.max(1, Math.round(params.roundingCents || 1));103 const totalC = roundCentsTo(sub2 + gstC + qstC, step);104 if (active && priced < active) warnings.push(`${active - priced} ligne(s) active(s) non chiffrée(s)`);105106 const wbsMap = new Map<string, { d: number; l: number }>();107 for (const r of results) { if (r.excluded) continue; const w = wbsMap.get(r.wbs) ?? { d: 0, l: 0 }; w.d += toCents(r.direct); w.l += toCents(r.located); wbsMap.set(r.wbs, w); }108 const byWbs = [...wbsMap.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([wbs, w]) => ({ wbs, direct: r2(w.d), located: r2(w.l), sharePct: locC > 0 ? Math.round((1000 * w.l) / locC) / 10 : 0 }));109110 return {111 computedAt: ctx.now, params, location: loc, lines: results, byWbs,112 totals: {113 direct: r2(dirC), located: r2(locC), locationAdjustment: r2(locC - dirC), generalConditions: r2(gcC), overhead: r2(ohC), profit: r2(prC), contingency: r2(ctC),114 subtotalBeforeEscalation: r2(sub1), escalation: r2(escC), subtotalBeforeTax: r2(sub2), gst: r2(gstC), qst: r2(qstC), total: r2(totalC),115 material: r2(matC), labour: r2(labC), equipment: r2(eqC), other: r2(othC),116 },117 applicationOrder: [118 "quantité = métré × facteur d'ajustement", "direct = quantité × coût unitaire (matériau + MO + équipement + autres, ou prix installé)",119 `facteur régional ${loc.code} (M ${loc.materialFactor} · MO ${loc.labourFactor} · É ${loc.equipmentFactor})`,120 `conditions générales ${params.generalConditionsPct} % du direct`, `frais généraux ${params.overheadPct} % du cumul`, `profit ${params.profitPct} % du cumul`,121 `contingence ${params.contingencyPct} % du direct`, f !== 1 ? `actualisation × ${f.toFixed(4)} (${params.escalationSource ?? "facteur saisi"})` : "aucune actualisation",122 `${params.applyGst ? `TPS ${GST_RATE} %` : "sans TPS"} · ${params.applyQst ? `TVQ ${QST_RATE} %` : "sans TVQ"}`,123 ],124 warnings, coverage: { linesPriced: priced, linesTotal: active, observedShare: locC > 0 ? Math.round((100 * obsW) / locC) / 100 : 0, assumptionShare: locC > 0 ? Math.round((100 * assW) / locC) / 100 : 0 },125 catalogueVersion: ctx.catalogueVersion, formulaVersion: ESTIMATE_FORMULA_VERSION,126 };127}128129export function defaultParams(priceDate: string, regionCode: string | null = null): EstimateParams {130 return { regionCode, priceDate, generalConditionsPct: 8, overheadPct: 7, profitPct: 8, contingencyPct: 5, escalateTo: null, escalationFactor: null, escalationSource: null, applyGst: false, applyQst: false, roundingCents: 1, rsmeansLocationId: null, rsmeansLabor: "STD" };131}132