// Glue Toveli → moteur Vrai-Prix (src/lib/engine.ts) sur la base canonique, en lecture seule. import { getDb } from "./db"; import { estimate, type CompInput, type EstimateResult, type MarketIndexPoint, type Subject } from "./engine"; import { categoryToTypeProp } from "./normalize"; export 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; } export interface ToveliEstimate { subject: Subject & { label: string; city: string | null; roleUnitId: string | null; source: "role" | "manual" }; result: EstimateResult; method: { name: string; version: string; date: string; inputs: string[]; limits: string[]; disclaimer: string }; roleUnit: RoleUnit | null; indexType: string; } const 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."; export function getRoleUnit(id: string): RoleUnit | null { return (getDb().prepare("SELECT * FROM role_unit WHERE id = ?").get(id) as RoleUnit | undefined) ?? null; } export function indexTypeFor(typeProp: string): string { if (typeProp === "condo_ou_multi") return "condo"; if (typeProp === "plex") return "plex"; if (typeProp === "unifamilial" || typeProp === "chalet" || typeProp === "maison_mobile") return "unifamilial"; return "indéterminé"; } function loadIndex(typeProp: string): MarketIndexPoint[] { return getDb().prepare("SELECT month, idx FROM market_index WHERE type_prop = ? ORDER BY month").all(indexTypeFor(typeProp)) as MarketIndexPoint[]; } /** Même stratégie que Vrai-Prix estimator.candidatesAround : bbox ±0,02° sur 30 mois, élargie si marché mince. */ export function candidatesAround(lat: number, lng: number): CompInput[] { const db = getDb(); 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_transaction 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`); const since = (months: number) => new Date(Date.now() - months * 30.44 * 86400000).toISOString().slice(0, 10); let rows = q.all({ lat, lng, h: 0.02, since: since(30), limit: 600 }) as CompInput[]; if (rows.length < 30) rows = q.all({ lat, lng, h: 0.1, since: since(30), limit: 600 }) as CompInput[]; if (rows.length < 30) rows = q.all({ lat, lng, h: 0.25, since: since(36), limit: 600 }) as CompInput[]; return rows; } function methodBlock(subject: Subject, hasModel: boolean, nCands: number) { const inputs = [`${nCands} ventes candidates (base de transactions Toveli, ≥ 50 000 $)`, "indice de marché mensuel par type (market_index)"]; 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"); if (subject.floorArea) inputs.push(`superficie habitable ${Math.round(subject.floorArea)} m²`); if (subject.yearBuilt) inputs.push(`année de construction ${subject.yearBuilt}`); 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."]; if (!hasModel) limits.unshift("Aucune estimation hédonique pour ce sujet : résultat basé uniquement sur les comparables ajustés."); 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 }; } export function estimateRoleUnit(id: string): ToveliEstimate | null { const u = getRoleUnit(id); if (!u || u.lat == null || u.lng == null) return null; const typeProp = u.type_prop ?? "autre"; 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 }; const cands = candidatesAround(u.lat, u.lng); const result = estimate(subject, cands, loadIndex(typeProp), new Date().toISOString()); if (!Number.isFinite(result.estimate)) return null; 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) }; } export interface ManualParams { lat: number; lng: number; category: string; floorAreaSqft?: number | null; yearBuilt?: number | null; landAreaSqft?: number | null; label?: string; city?: string | null; } export function estimateManual(p: ManualParams): ToveliEstimate | null { const typeProp = categoryToTypeProp(p.category); 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 }; const cands = candidatesAround(p.lat, p.lng); const result = estimate(subject, cands, loadIndex(typeProp), new Date().toISOString()); if (!Number.isFinite(result.estimate) || result.nCompsUsed < 3) return null; 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) }; } /** Pour une annonce à vendre : estimation en direct si l'unité du rôle est connue, sinon manuelle à partir des attributs de l'annonce. */ export 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 { if (roleUnitId) { const e = estimateRoleUnit(roleUnitId); if (e) return e; } if (l.lat == null || l.lng == null || l.category === "terrain" || l.category === "commercial") return null; 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 }); }