// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Atelier « mon évaluation » d'une propriété à vendre : construit le bassin de * comparables (ventes réelles autour du sujet, + annonces actives voisines) à * partir duquel l'utilisateur choisit, ajuste et réconcilie SA valeur — puis la * compare à la mesure Vrai-Prix déjà calculée (table vp_eval). * * Chaque candidat porte les ajustements du moteur (marché, superficie, âge) et * son poids gaussien, en dollars, pour servir de point de départ transparent. */ import { getCandidates, getMarketIndex, getUnit, type UnitRow } from "./db"; import { adjustComps, ageAdjustment, areaAdjustment, estimate, haversineM, monthsBetween, timeFactor, type CompInput, type Subject, } from "./engine"; import { candidatesAround, indexType, toComps } from "./estimator"; import { getListing, groupToTypeProp, listingsNear, type ListingDetail } from "./immoka"; export interface CompCandidate { id: string; kind: "sale" | "listing"; date: string; // vente : date de l'acte ; annonce : aujourd'hui (prix demandé courant) amount: number; lat: number; lng: number; distanceM: number; monthsAgo: number; street: string | null; city: string | null; propertyType: string | null; yearBuilt: number | null; floorArea: number | null; // m² landArea: number | null; // m² valeurRole: number | null; adjTime: number; adjArea: number; adjAge: number; adjustedPrice: number; weight: number; engineUsed: boolean; // fait partie des 12 comparables retenus par le moteur idProvinc?: string | null; uid?: string; // annonce Immo-Ka image?: string | null; } export interface CompsSubject { uid: string; address: string | null; city: string | null; lat: number; lng: number; typeProp: string; floorArea: number | null; yearBuilt: number | null; landArea: number | null; price: number; unitId: string | null; fromUnit: boolean; // caractéristiques lues au rôle (unité jumelée) vs à l'annonce } export interface CompsPayload { subject: CompsSubject; engine: { estimate: number | null; low: number | null; high: number | null; modelEstimate: number | null; compsEstimate: number | null; modelWeight: number; nComps: number; compIds: string[]; }; comps: CompCandidate[]; index: { month: string; idx: number }[]; nowISO: string; params: { months: number; radiusKm: number; includeListings: boolean; limit: number }; } function since(months: number): string { const d = new Date(); d.setMonth(d.getMonth() - months); return d.toISOString().slice(0, 10); } const SQFT = 0.092903; /** Sujet du calcul : l'unité du rôle jumelée si disponible, sinon l'annonce. */ export function subjectFor(det: ListingDetail, unit: UnitRow | null): CompsSubject | null { const l = det.listing; if (unit) { return { uid: l.uid, address: l.address, city: l.city, lat: unit.lat, lng: unit.lng, typeProp: unit.type_prop, floorArea: unit.aire_etages_m2, yearBuilt: unit.annee_construction, landArea: unit.superficie_terrain_m2, price: l.price, unitId: unit.id_provinc, fromUnit: true, }; } if (l.lat == null || l.lng == null) return null; const area = l.area_sqft && l.area_sqft > 100 && l.area_sqft < 20000 ? l.area_sqft * SQFT : null; return { uid: l.uid, address: l.address, city: l.city, lat: l.lat, lng: l.lng, typeProp: groupToTypeProp(det.group), floorArea: area, yearBuilt: l.year_built && l.year_built > 1600 ? l.year_built : null, landArea: l.lot_sqft && l.lot_sqft > 50 ? l.lot_sqft * SQFT : null, price: l.price, unitId: null, fromUnit: false, }; } function weightOf(distanceM: number, monthsAgo: number, subjectArea: number | null, compArea: number | null): number { const areaDiffPct = subjectArea && compArea ? Math.abs(compArea - subjectArea) / subjectArea : 0.15; return ( Math.exp(-((distanceM / 1500) ** 2)) * Math.exp(-((monthsAgo / 24) ** 2)) * Math.exp(-((areaDiffPct / 0.25) ** 2)) ); } export function buildComps( uid: string, opts: { months?: number; radiusKm?: number; includeListings?: boolean; limit?: number } = {} ): CompsPayload | null { const det = getListing(uid); if (!det) return null; const unit = det.eval?.unit_id ? (getUnit(det.eval.unit_id) ?? null) : null; const subj = subjectFor(det, unit); if (!subj) return null; const months = Math.min(Math.max(opts.months ?? 24, 6), 60); const radiusKm = Math.min(Math.max(opts.radiusKm ?? 3, 0.5), 25); const includeListings = opts.includeListings ?? true; const limit = Math.min(Math.max(opts.limit ?? 60, 10), 150); const nowISO = new Date().toISOString().slice(0, 10); const index = getMarketIndex(indexType(subj.typeProp)); const subject: Subject = { lat: subj.lat, lng: subj.lng, typeProp: subj.typeProp, floorArea: subj.floorArea, yearBuilt: subj.yearBuilt, landArea: subj.landArea, modelEstimate: unit?.est_2026 ?? null, modelP10: unit?.p10 ?? null, modelP90: unit?.p90 ?? null, }; // ce que le moteur retient lui-même (mêmes règles que la fiche /estimation) const engineRes = estimate(subject, toComps(candidatesAround(subj.lat, subj.lng)), index, nowISO); const engineIds = new Set(engineRes.comps.map((c) => c.id)); // bassin élargi de ventes : rayon et fenêtre choisis par l'utilisateur const halfDeg = (radiusKm / 111) * 1.05; const rows = getCandidates(subj.lat, subj.lng, halfDeg, since(months), 4000); const sales: CompCandidate[] = []; for (const r of rows) { const distanceM = haversineM(subj.lat, subj.lng, r.lat, r.lng); if (distanceM > radiusKm * 1000) continue; const monthsAgo = monthsBetween(r.date, nowISO); const tf = timeFactor(r.date.slice(0, 7), index); const adjTime = r.amount * (tf - 1); const adjArea = areaAdjustment(subj.floorArea, { floorArea: r.floor_area, amount: r.amount }); const adjAge = ageAdjustment(subj.yearBuilt, { yearBuilt: r.year_built, amount: r.amount }); sales.push({ id: r.id, kind: "sale", date: r.date, amount: r.amount, lat: r.lat, lng: r.lng, distanceM: Math.round(distanceM), monthsAgo: Math.round(monthsAgo * 10) / 10, street: r.street, city: r.city, propertyType: r.property_type, yearBuilt: r.year_built, floorArea: r.floor_area, landArea: r.land_area, valeurRole: r.valeur_role, adjTime: Math.round(adjTime), adjArea: Math.round(adjArea), adjAge: Math.round(adjAge), adjustedPrice: Math.round(r.amount + adjTime + adjArea + adjAge), weight: weightOf(distanceM, monthsAgo, subj.floorArea, r.floor_area), engineUsed: engineIds.has(r.id), idProvinc: r.id_provinc, }); } // les comparables du moteur d'abord, puis par poids décroissant sales.sort((a, b) => Number(b.engineUsed) - Number(a.engineUsed) || b.weight - a.weight); const kept = sales.slice(0, limit); const comps: CompCandidate[] = [...kept]; if (includeListings) { const near = listingsNear(subj.lat, subj.lng, radiusKm, { group: det.group, excludeUid: uid, limit: 30 }); for (const c of near) { if (c.lat == null || c.lng == null) continue; const area = c.areaSqft && c.areaSqft > 100 && c.areaSqft < 20000 ? c.areaSqft * SQFT : null; const adjArea = areaAdjustment(subj.floorArea, { floorArea: area, amount: c.price }); const adjAge = ageAdjustment(subj.yearBuilt, { yearBuilt: c.yearBuilt, amount: c.price }); comps.push({ id: `listing:${c.uid}`, kind: "listing", date: nowISO, amount: c.price, lat: c.lat, lng: c.lng, distanceM: c.distanceM ?? Math.round(haversineM(subj.lat, subj.lng, c.lat, c.lng)), monthsAgo: 0, street: c.address, city: c.city, propertyType: c.propertyType, yearBuilt: c.yearBuilt, floorArea: area, landArea: c.lotSqft && c.lotSqft > 50 ? c.lotSqft * SQFT : null, valeurRole: null, adjTime: 0, adjArea: Math.round(adjArea), adjAge: Math.round(adjAge), adjustedPrice: Math.round(c.price + adjArea + adjAge), weight: weightOf(c.distanceM ?? 0, 0, subj.floorArea, area), engineUsed: false, uid: c.uid, image: c.image, }); } } return { subject: subj, engine: { estimate: Number.isFinite(engineRes.estimate) ? engineRes.estimate : null, low: Number.isFinite(engineRes.low) ? engineRes.low : null, high: Number.isFinite(engineRes.high) ? engineRes.high : null, modelEstimate: engineRes.modelEstimate, compsEstimate: engineRes.compsEstimate, modelWeight: engineRes.modelWeight, nComps: engineRes.nCompsUsed, compIds: [...engineIds], }, comps, index, nowISO, params: { months, radiusKm, includeListings, limit }, }; } // réexport pratique pour les routes export type { CompInput }; export { adjustComps };