SPB Git

Repository Public

estimate-engine.ts 9.4 KB 132 lines typescript

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
Files main
Menu

Theme