// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Validation stricte des observations canoniques (sans dépendance) : * prix ≤ 0, devise ≠ CAD, unité inconnue, conversion incohérente, aberration * par rapport aux prix récents (MAD/IQR) et aux bornes métier (référence * interne ×0,25…×4). Les rejets sont conservés en base, jamais supprimés. */ import { isKnownUnit } from "../units"; import { detectOutliers } from "../pricing"; import type { CanonicalObservation, ValidationResult } from "./types"; export interface ValidateContext { /** prix récents (unité canonique) par article, autres sources incluses */ recentPrices: (itemCode: string) => number[]; referencePrice: (itemCode: string) => number | null; } const UNIT_MAX: Record = { 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 }; export function validateObservations(obs: CanonicalObservation[], ctx: ValidateContext): ValidationResult { const accepted: CanonicalObservation[] = []; const rejected: { obs: CanonicalObservation; reason: string }[] = []; const priceGroups = new Map(); for (const o of obs) { const reason = basicReason(o, ctx); if (reason) { rejected.push({ obs: o, reason }); continue; } accepted.push(o); if (o.kind === "price") priceGroups.set(o.itemCode, [...(priceGroups.get(o.itemCode) ?? []), o.totalCost]); } // aberrations statistiques : observation vs prix récents (autres sources) + celles du lot const final: CanonicalObservation[] = []; for (const o of accepted) { if (o.kind !== "price") { final.push(o); continue; } const pool = [...ctx.recentPrices(o.itemCode), ...(priceGroups.get(o.itemCode) ?? []).filter((v) => v !== o.totalCost)]; if (pool.length >= 2) { const arr = [...pool, o.totalCost]; const out = detectOutliers(arr); const hit = out.find((x) => x.idx === arr.length - 1); if (hit) { rejected.push({ obs: o, reason: `aberration : ${hit.reason}` }); continue; } } final.push(o); } return { accepted: final, rejected }; } function basicReason(o: CanonicalObservation, ctx: ValidateContext): string | null { if (o.kind === "index") { if (!Number.isFinite(o.value) || o.value <= 0) return "valeur d'indice invalide"; if (!/^\d{4}-\d{2}-\d{2}$/.test(o.period)) return "période invalide"; return null; } if (o.kind === "labour") { if (!(o.baseWage > 10 && o.baseWage < 200)) return `taux horaire implausible (${o.baseWage})`; if (!(o.totalEmployerCost >= o.baseWage && o.totalEmployerCost < o.baseWage * 2.2)) return `coût employeur incohérent (${o.totalEmployerCost} vs ${o.baseWage})`; if (!/^\d{4}-\d{2}-\d{2}$/.test(o.effectiveFrom)) return "date d'entrée en vigueur invalide"; return null; } if (o.currency !== "CAD") return `devise impossible (${o.currency})`; if (!(o.rawPrice > 0)) return "prix ≤ 0"; if (!isKnownUnit(o.sourceUnit)) return `unité inconnue « ${o.sourceUnit} »`; if (!(o.conversionFactor > 0) || !Number.isFinite(o.conversionFactor)) return "facteur de conversion invalide"; if (!(o.totalCost > 0)) return "prix canonique ≤ 0"; const max = UNIT_MAX[o.canonicalUnit] ?? 1e6; if (o.totalCost > max) return `prix par ${o.canonicalUnit} hors bornes (${o.totalCost.toFixed(2)} > ${max}) — emballage probablement mal interprété`; const ref = ctx.referencePrice(o.itemCode); if (ref != null && ref > 0) { const ratio = o.totalCost / ref; if (ratio > 4 || ratio < 0.25) return `hors bornes métier : ${ratio.toFixed(2)}× le prix de référence (${ref})`; } return null; }