TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1// Glue Toveli → moteur Vrai-Prix (src/lib/engine.ts) sur la base canonique, en lecture seule.2import { getDb } from "./db";3import { estimate, type CompInput, type EstimateResult, type MarketIndexPoint, type Subject } from "./engine";4import { categoryToTypeProp } from "./normalize";56export interface RoleUnit { id: string; address: string | null; unit: string | null; city: string | null; lat: number | null; lng: number | null; type_prop: string | null; cubf_label: string | null; year_built: number | null; floor_area_m2: number | null; land_area_m2: number | null; n_units: number | null; role_land: number | null; role_building: number | null; role_value: number | null; role_market_date: string | null; est: number | null; p10: number | null; p90: number | null; }78export interface ToveliEstimate {9 subject: Subject & { label: string; city: string | null; roleUnitId: string | null; source: "role" | "manual" };10 result: EstimateResult;11 method: { name: string; version: string; date: string; inputs: string[]; limits: string[]; disclaimer: string };12 roleUnit: RoleUnit | null;13 indexType: string;14}1516const DISCLAIMER = "Estimation statistique indicative produite par un modèle (hédonique + comparables). Elle ne constitue pas une évaluation agréée (OEAQ) ni un conseil financier ; l'état intérieur, les rénovations et les particularités non observées ne sont pas pris en compte.";1718export function getRoleUnit(id: string): RoleUnit | null {19 return (getDb().prepare("SELECT * FROM role_unit WHERE id = ?").get(id) as RoleUnit | undefined) ?? null;20}2122export function indexTypeFor(typeProp: string): string {23 if (typeProp === "condo_ou_multi") return "condo";24 if (typeProp === "plex") return "plex";25 if (typeProp === "unifamilial" || typeProp === "chalet" || typeProp === "maison_mobile") return "unifamilial";26 return "indéterminé";27}2829function loadIndex(typeProp: string): MarketIndexPoint[] {30 return getDb().prepare("SELECT month, idx FROM market_index WHERE type_prop = ? ORDER BY month").all(indexTypeFor(typeProp)) as MarketIndexPoint[];31}3233/** Même stratégie que Vrai-Prix estimator.candidatesAround : bbox ±0,02° sur 30 mois, élargie si marché mince. */34export function candidatesAround(lat: number, lng: number): CompInput[] {35 const db = getDb();36 const q = db.prepare(`SELECT id, date, amount, lat, lng, property_type AS propertyType, year_built AS yearBuilt, floor_area AS floorArea, street, city FROM sale_transaction37 WHERE lat BETWEEN @lat-@h AND @lat+@h AND lng BETWEEN @lng-@h*1.4 AND @lng+@h*1.4 AND date >= @since AND amount >= 50000 ORDER BY date DESC LIMIT @limit`);38 const since = (months: number) => new Date(Date.now() - months * 30.44 * 86400000).toISOString().slice(0, 10);39 let rows = q.all({ lat, lng, h: 0.02, since: since(30), limit: 600 }) as CompInput[];40 if (rows.length < 30) rows = q.all({ lat, lng, h: 0.1, since: since(30), limit: 600 }) as CompInput[];41 if (rows.length < 30) rows = q.all({ lat, lng, h: 0.25, since: since(36), limit: 600 }) as CompInput[];42 return rows;43}4445function methodBlock(subject: Subject, hasModel: boolean, nCands: number) {46 const inputs = [`${nCands} ventes candidates (base de transactions Toveli, ≥ 50 000 $)`, "indice de marché mensuel par type (market_index)"];47 if (hasModel) inputs.unshift("estimation hédonique pré-calculée pour l'unité du rôle (LightGBM, ~690 k ventes 2021-2026) avec P10/P90");48 if (subject.floorArea) inputs.push(`superficie habitable ${Math.round(subject.floorArea)} m²`);49 if (subject.yearBuilt) inputs.push(`année de construction ${subject.yearBuilt}`);50 const limits = ["Rénovations, état intérieur, garage, piscine et vue ne sont pas observés.", "Marchés ruraux peu liquides : fourchette élargie, confiance plus faible.", "Les tours de condos partagent souvent une même géométrie au rôle."];51 if (!hasModel) limits.unshift("Aucune estimation hédonique pour ce sujet : résultat basé uniquement sur les comparables ajustés.");52 return { name: "Hybride Toveli : modèle hédonique (65 %) + comparables ajustés (35 %)", version: "toveli-estim-2026.10 (hédonique 2026-08)", date: new Date().toISOString().slice(0, 10), inputs, limits, disclaimer: DISCLAIMER };53}5455export function estimateRoleUnit(id: string): ToveliEstimate | null {56 const u = getRoleUnit(id);57 if (!u || u.lat == null || u.lng == null) return null;58 const typeProp = u.type_prop ?? "autre";59 const subject: Subject = { lat: u.lat, lng: u.lng, typeProp, floorArea: u.floor_area_m2, yearBuilt: u.year_built, landArea: u.land_area_m2, modelEstimate: u.est, modelP10: u.p10, modelP90: u.p90 };60 const cands = candidatesAround(u.lat, u.lng);61 const result = estimate(subject, cands, loadIndex(typeProp), new Date().toISOString());62 if (!Number.isFinite(result.estimate)) return null;63 return { subject: { ...subject, label: [u.address, u.unit ? `app. ${u.unit}` : null].filter(Boolean).join(", "), city: u.city, roleUnitId: u.id, source: "role" }, result, method: methodBlock(subject, u.est != null, cands.length), roleUnit: u, indexType: indexTypeFor(typeProp) };64}6566export interface ManualParams { lat: number; lng: number; category: string; floorAreaSqft?: number | null; yearBuilt?: number | null; landAreaSqft?: number | null; label?: string; city?: string | null; }6768export function estimateManual(p: ManualParams): ToveliEstimate | null {69 const typeProp = categoryToTypeProp(p.category);70 const subject: Subject = { lat: p.lat, lng: p.lng, typeProp, floorArea: p.floorAreaSqft ? p.floorAreaSqft * 0.09290304 : null, yearBuilt: p.yearBuilt ?? null, landArea: p.landAreaSqft ? p.landAreaSqft * 0.09290304 : null };71 const cands = candidatesAround(p.lat, p.lng);72 const result = estimate(subject, cands, loadIndex(typeProp), new Date().toISOString());73 if (!Number.isFinite(result.estimate) || result.nCompsUsed < 3) return null;74 return { subject: { ...subject, label: p.label ?? "Propriété", city: p.city ?? null, roleUnitId: null, source: "manual" }, result, method: methodBlock(subject, false, cands.length), roleUnit: null, indexType: indexTypeFor(typeProp) };75}7677/** Pour une annonce à vendre : estimation en direct si l'unité du rôle est connue, sinon manuelle à partir des attributs de l'annonce. */78export function estimateForListing(l: { lat: number | null; lng: number | null; category: string; area_sqft: number | null; year_built: number | null; lot_sqft: number | null; address: string | null; city: string | null }, roleUnitId: string | null): ToveliEstimate | null {79 if (roleUnitId) { const e = estimateRoleUnit(roleUnitId); if (e) return e; }80 if (l.lat == null || l.lng == null || l.category === "terrain" || l.category === "commercial") return null;81 return estimateManual({ lat: l.lat, lng: l.lng, category: l.category, floorAreaSqft: l.area_sqft, yearBuilt: l.year_built, landAreaSqft: l.lot_sqft, label: l.address ?? "Propriété", city: l.city });82}83