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 conversion d'unités de la base de coûts. Module PUR.4 * Règle : on conserve TOUJOURS l'unité d'origine (source_unit), l'unité5 * canonique (canonical_unit) et le facteur de conversion appliqué.6 */7import type { CanonicalUnit } from "./types";89export const SQFT_PER_M2 = 10.7639;10export const M2_PER_SQFT = 0.092903;11export const FT_PER_M = 3.28084;12export const M3_PER_FT3 = 0.0283168;13export const LB_PER_KG = 2.20462;14export const L_PER_GAL_US = 3.78541;15export const L_PER_GAL_IMP = 4.54609;1617/** Libellés FR/EN des unités canoniques. */18export const UNIT_LABELS: Record<CanonicalUnit, { fr: string; en: string }> = {19 unit: { fr: "unité", en: "each" },20 pi2: { fr: "pi²", en: "sq ft" },21 pi_lin: { fr: "pi lin.", en: "lin ft" },22 pi3: { fr: "pi³", en: "cu ft" },23 m2: { fr: "m²", en: "m²" },24 m3: { fr: "m³", en: "m³" },25 kg: { fr: "kg", en: "kg" },26 lb: { fr: "lb", en: "lb" },27 L: { fr: "L", en: "L" },28 gal: { fr: "gal", en: "gal" },29 h: { fr: "heure", en: "hour" },30 day: { fr: "jour", en: "day" },31 lump: { fr: "forfait", en: "lump sum" },32};3334/** Unités source reconnues (normalisées en minuscules, sans accents). */35export type SourceUnit =36 | CanonicalUnit37 | "chaque"38 | "each"39 | "ea"40 | "paquet"41 | "pack"42 | "boite"43 | "box"44 | "sac"45 | "bag"46 | "rouleau"47 | "roll"48 | "feuille"49 | "sheet"50 | "seau"51 | "pail"52 | "planche"53 | "board"54 | "longueur"55 | "length"56 | "ballot"57 | "bundle"58 | "verge3"59 | "cu_yd"60 | "tonne"61 | "kit";6263export interface UnitConversion {64 sourceUnit: string;65 canonicalUnit: CanonicalUnit;66 /** prix canonique = prix source × factor (ex. feuille 32 pi² → factor 1/32) */67 factor: number;68 method: string;69}7071export function normalizeUnitToken(raw: string): string {72 return raw73 .normalize("NFD")74 .replace(/[̀-ͯ]/g, "")75 .toLowerCase()76 .replace(/[^a-z0-9/]/g, "")77 .trim();78}7980/** Unités « emballage » qui nécessitent une quantité par emballage propre à l'article. */81const PACK_UNITS = new Set(["paquet", "pack", "boite", "box", "sac", "bag", "rouleau", "roll", "feuille", "sheet", "seau", "pail", "planche", "board", "longueur", "length", "ballot", "bundle", "kit", "pqt", "bte"]);8283const DIRECT: Record<string, CanonicalUnit> = {84 unit: "unit", chaque: "unit", each: "unit", ea: "unit", ch: "unit", un: "unit", unite: "unit",85 pi2: "pi2", pi: "pi_lin", sqft: "pi2", "pi²": "pi2", pieds2: "pi2", piedcarre: "pi2", ft2: "pi2",86 pilin: "pi_lin", lf: "pi_lin", linft: "pi_lin", pl: "pi_lin", ft: "pi_lin",87 pi3: "pi3", ft3: "pi3", m2: "m2", m3: "m3", kg: "kg", lb: "lb", lbs: "lb", l: "L", litre: "L", liter: "L",88 gal: "gal", gallon: "gal", h: "h", heure: "h", hour: "h", hr: "h", day: "day", jour: "day", lump: "lump", forfait: "lump",89};9091/**92 * Convertit un prix exprimé dans une unité source vers l'unité canonique de93 * l'article. `packQty` = contenu d'un emballage exprimé dans l'unité canonique94 * (ex. feuille de gypse 4×8 → 32 pi² ; paquet d'isolant R24 → 78,3 pi²).95 */96export function convertPrice(97 price: number,98 sourceUnitRaw: string,99 canonical: CanonicalUnit,100 packQty?: number | null101): { price: number; conversion: UnitConversion } {102 const tok = normalizeUnitToken(sourceUnitRaw);103 const direct = DIRECT[tok];104 if (direct === canonical) {105 return { price, conversion: { sourceUnit: sourceUnitRaw, canonicalUnit: canonical, factor: 1, method: "identité" } };106 }107 // conversions métriques ↔ impériales simples108 const pair = `${direct ?? tok}->${canonical}`;109 const SIMPLE: Record<string, [number, string]> = {110 "m2->pi2": [1 / SQFT_PER_M2, "1 m² = 10,764 pi²"],111 "pi2->m2": [SQFT_PER_M2, "1 m² = 10,764 pi²"],112 "m3->pi3": [M3_PER_FT3, "1 pi³ = 0,0283 m³"],113 "pi3->m3": [1 / M3_PER_FT3, "1 pi³ = 0,0283 m³"],114 "verge3->m3": [1 / 0.764555, "1 vg³ = 0,7646 m³"],115 "cu_yd->m3": [1 / 0.764555, "1 vg³ = 0,7646 m³"],116 "kg->lb": [1 / LB_PER_KG, "1 kg = 2,2046 lb"],117 "lb->kg": [LB_PER_KG, "1 kg = 2,2046 lb"],118 "gal->L": [1 / L_PER_GAL_US, "1 gal US = 3,785 L"],119 "L->gal": [L_PER_GAL_US, "1 gal US = 3,785 L"],120 "m->pi_lin": [1 / FT_PER_M, "1 m = 3,2808 pi"],121 "pi_lin->m": [FT_PER_M, "1 m = 3,2808 pi"],122 };123 const s = SIMPLE[pair];124 if (s) return { price: price * s[0], conversion: { sourceUnit: sourceUnitRaw, canonicalUnit: canonical, factor: s[0], method: s[1] } };125 // emballages : besoin de la quantité par emballage126 if ((PACK_UNITS.has(tok) || direct === "unit" || tok === "") && packQty && packQty > 0) {127 const f = 1 / packQty;128 return {129 price: price * f,130 conversion: { sourceUnit: sourceUnitRaw, canonicalUnit: canonical, factor: f, method: `1 ${sourceUnitRaw || "emballage"} = ${packQty} ${UNIT_LABELS[canonical].fr}` },131 };132 }133 throw new Error(`Conversion impossible : « ${sourceUnitRaw} » → ${canonical}${packQty ? "" : " (quantité par emballage inconnue)"}`);134}135136/** Vrai si l'unité source est reconnue (canonique, métrique/impériale ou emballage). */137export function isKnownUnit(raw: string): boolean {138 const tok = normalizeUnitToken(raw);139 return tok in DIRECT || PACK_UNITS.has(tok) || ["m", "verge3", "cu_yd", "tonne"].includes(tok);140}141142export const sqftFromM2 = (m2: number): number => m2 * SQFT_PER_M2;143export const m2FromSqft = (sqft: number): number => sqft * M2_PER_SQFT;144