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 * Validation stricte des observations canoniques (sans dépendance) :4 * prix ≤ 0, devise ≠ CAD, unité inconnue, conversion incohérente, aberration5 * par rapport aux prix récents (MAD/IQR) et aux bornes métier (référence6 * interne ×0,25…×4). Les rejets sont conservés en base, jamais supprimés.7 */8import { isKnownUnit } from "../units";9import { detectOutliers } from "../pricing";10import type { CanonicalObservation, ValidationResult } from "./types";1112export interface ValidateContext {13 /** prix récents (unité canonique) par article, autres sources incluses */14 recentPrices: (itemCode: string) => number[];15 referencePrice: (itemCode: string) => number | null;16}1718const UNIT_MAX: Record<string, number> = { pi2: 400, pi_lin: 300, unit: 200000, lump: 500000, m3: 2000, kg: 500, lb: 500, L: 500, gal: 500, h: 1000, day: 5000, pi3: 500, m2: 5000 };1920export function validateObservations(obs: CanonicalObservation[], ctx: ValidateContext): ValidationResult {21 const accepted: CanonicalObservation[] = [];22 const rejected: { obs: CanonicalObservation; reason: string }[] = [];23 const priceGroups = new Map<string, number[]>();2425 for (const o of obs) {26 const reason = basicReason(o, ctx);27 if (reason) { rejected.push({ obs: o, reason }); continue; }28 accepted.push(o);29 if (o.kind === "price") priceGroups.set(o.itemCode, [...(priceGroups.get(o.itemCode) ?? []), o.totalCost]);30 }31 // aberrations statistiques : observation vs prix récents (autres sources) + celles du lot32 const final: CanonicalObservation[] = [];33 for (const o of accepted) {34 if (o.kind !== "price") { final.push(o); continue; }35 const pool = [...ctx.recentPrices(o.itemCode), ...(priceGroups.get(o.itemCode) ?? []).filter((v) => v !== o.totalCost)];36 if (pool.length >= 2) {37 const arr = [...pool, o.totalCost];38 const out = detectOutliers(arr);39 const hit = out.find((x) => x.idx === arr.length - 1);40 if (hit) { rejected.push({ obs: o, reason: `aberration : ${hit.reason}` }); continue; }41 }42 final.push(o);43 }44 return { accepted: final, rejected };45}4647function basicReason(o: CanonicalObservation, ctx: ValidateContext): string | null {48 if (o.kind === "index") {49 if (!Number.isFinite(o.value) || o.value <= 0) return "valeur d'indice invalide";50 if (!/^\d{4}-\d{2}-\d{2}$/.test(o.period)) return "période invalide";51 return null;52 }53 if (o.kind === "labour") {54 if (!(o.baseWage > 10 && o.baseWage < 200)) return `taux horaire implausible (${o.baseWage})`;55 if (!(o.totalEmployerCost >= o.baseWage && o.totalEmployerCost < o.baseWage * 2.2)) return `coût employeur incohérent (${o.totalEmployerCost} vs ${o.baseWage})`;56 if (!/^\d{4}-\d{2}-\d{2}$/.test(o.effectiveFrom)) return "date d'entrée en vigueur invalide";57 return null;58 }59 if (o.currency !== "CAD") return `devise impossible (${o.currency})`;60 if (!(o.rawPrice > 0)) return "prix ≤ 0";61 if (!isKnownUnit(o.sourceUnit)) return `unité inconnue « ${o.sourceUnit} »`;62 if (!(o.conversionFactor > 0) || !Number.isFinite(o.conversionFactor)) return "facteur de conversion invalide";63 if (!(o.totalCost > 0)) return "prix canonique ≤ 0";64 const max = UNIT_MAX[o.canonicalUnit] ?? 1e6;65 if (o.totalCost > max) return `prix par ${o.canonicalUnit} hors bornes (${o.totalCost.toFixed(2)} > ${max}) — emballage probablement mal interprété`;66 const ref = ctx.referencePrice(o.itemCode);67 if (ref != null && ref > 0) {68 const ratio = o.totalCost / ref;69 if (ratio > 4 || ratio < 0.25) return `hors bornes métier : ${ratio.toFixed(2)}× le prix de référence (${ref})`;70 }71 return null;72}73