// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Moteur de conversion d'unités de la base de coûts. Module PUR. * Règle : on conserve TOUJOURS l'unité d'origine (source_unit), l'unité * canonique (canonical_unit) et le facteur de conversion appliqué. */ import type { CanonicalUnit } from "./types"; export const SQFT_PER_M2 = 10.7639; export const M2_PER_SQFT = 0.092903; export const FT_PER_M = 3.28084; export const M3_PER_FT3 = 0.0283168; export const LB_PER_KG = 2.20462; export const L_PER_GAL_US = 3.78541; export const L_PER_GAL_IMP = 4.54609; /** Libellés FR/EN des unités canoniques. */ export const UNIT_LABELS: Record = { unit: { fr: "unité", en: "each" }, pi2: { fr: "pi²", en: "sq ft" }, pi_lin: { fr: "pi lin.", en: "lin ft" }, pi3: { fr: "pi³", en: "cu ft" }, m2: { fr: "m²", en: "m²" }, m3: { fr: "m³", en: "m³" }, kg: { fr: "kg", en: "kg" }, lb: { fr: "lb", en: "lb" }, L: { fr: "L", en: "L" }, gal: { fr: "gal", en: "gal" }, h: { fr: "heure", en: "hour" }, day: { fr: "jour", en: "day" }, lump: { fr: "forfait", en: "lump sum" }, }; /** Unités source reconnues (normalisées en minuscules, sans accents). */ export type SourceUnit = | CanonicalUnit | "chaque" | "each" | "ea" | "paquet" | "pack" | "boite" | "box" | "sac" | "bag" | "rouleau" | "roll" | "feuille" | "sheet" | "seau" | "pail" | "planche" | "board" | "longueur" | "length" | "ballot" | "bundle" | "verge3" | "cu_yd" | "tonne" | "kit"; export interface UnitConversion { sourceUnit: string; canonicalUnit: CanonicalUnit; /** prix canonique = prix source × factor (ex. feuille 32 pi² → factor 1/32) */ factor: number; method: string; } export function normalizeUnitToken(raw: string): string { return raw .normalize("NFD") .replace(/[̀-ͯ]/g, "") .toLowerCase() .replace(/[^a-z0-9/]/g, "") .trim(); } /** Unités « emballage » qui nécessitent une quantité par emballage propre à l'article. */ const 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"]); const DIRECT: Record = { unit: "unit", chaque: "unit", each: "unit", ea: "unit", ch: "unit", un: "unit", unite: "unit", pi2: "pi2", pi: "pi_lin", sqft: "pi2", "pi²": "pi2", pieds2: "pi2", piedcarre: "pi2", ft2: "pi2", pilin: "pi_lin", lf: "pi_lin", linft: "pi_lin", pl: "pi_lin", ft: "pi_lin", pi3: "pi3", ft3: "pi3", m2: "m2", m3: "m3", kg: "kg", lb: "lb", lbs: "lb", l: "L", litre: "L", liter: "L", gal: "gal", gallon: "gal", h: "h", heure: "h", hour: "h", hr: "h", day: "day", jour: "day", lump: "lump", forfait: "lump", }; /** * Convertit un prix exprimé dans une unité source vers l'unité canonique de * l'article. `packQty` = contenu d'un emballage exprimé dans l'unité canonique * (ex. feuille de gypse 4×8 → 32 pi² ; paquet d'isolant R24 → 78,3 pi²). */ export function convertPrice( price: number, sourceUnitRaw: string, canonical: CanonicalUnit, packQty?: number | null ): { price: number; conversion: UnitConversion } { const tok = normalizeUnitToken(sourceUnitRaw); const direct = DIRECT[tok]; if (direct === canonical) { return { price, conversion: { sourceUnit: sourceUnitRaw, canonicalUnit: canonical, factor: 1, method: "identité" } }; } // conversions métriques ↔ impériales simples const pair = `${direct ?? tok}->${canonical}`; const SIMPLE: Record = { "m2->pi2": [1 / SQFT_PER_M2, "1 m² = 10,764 pi²"], "pi2->m2": [SQFT_PER_M2, "1 m² = 10,764 pi²"], "m3->pi3": [M3_PER_FT3, "1 pi³ = 0,0283 m³"], "pi3->m3": [1 / M3_PER_FT3, "1 pi³ = 0,0283 m³"], "verge3->m3": [1 / 0.764555, "1 vg³ = 0,7646 m³"], "cu_yd->m3": [1 / 0.764555, "1 vg³ = 0,7646 m³"], "kg->lb": [1 / LB_PER_KG, "1 kg = 2,2046 lb"], "lb->kg": [LB_PER_KG, "1 kg = 2,2046 lb"], "gal->L": [1 / L_PER_GAL_US, "1 gal US = 3,785 L"], "L->gal": [L_PER_GAL_US, "1 gal US = 3,785 L"], "m->pi_lin": [1 / FT_PER_M, "1 m = 3,2808 pi"], "pi_lin->m": [FT_PER_M, "1 m = 3,2808 pi"], }; const s = SIMPLE[pair]; if (s) return { price: price * s[0], conversion: { sourceUnit: sourceUnitRaw, canonicalUnit: canonical, factor: s[0], method: s[1] } }; // emballages : besoin de la quantité par emballage if ((PACK_UNITS.has(tok) || direct === "unit" || tok === "") && packQty && packQty > 0) { const f = 1 / packQty; return { price: price * f, conversion: { sourceUnit: sourceUnitRaw, canonicalUnit: canonical, factor: f, method: `1 ${sourceUnitRaw || "emballage"} = ${packQty} ${UNIT_LABELS[canonical].fr}` }, }; } throw new Error(`Conversion impossible : « ${sourceUnitRaw} » → ${canonical}${packQty ? "" : " (quantité par emballage inconnue)"}`); } /** Vrai si l'unité source est reconnue (canonique, métrique/impériale ou emballage). */ export function isKnownUnit(raw: string): boolean { const tok = normalizeUnitToken(raw); return tok in DIRECT || PACK_UNITS.has(tok) || ["m", "verge3", "cu_yd", "tonne"].includes(tok); } export const sqftFromM2 = (m2: number): number => m2 * SQFT_PER_M2; export const m2FromSqft = (sqft: number): number => sqft * M2_PER_SQFT;