Python 61.6%
TypeScript 20.9%
CSS 11.4%
JavaScript 5.1%
HTML 1.1%
1// -----------------------------------------------------------------------------2// Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/synthese.ts : synthèse DÉTERMINISTE de la fiche véhicule (aucun LLM,5// aucune donnée inventée) :6// · comparaisonPrix : position du prix face à la médiane des comparables7// (analyse de marché calculée côté API : autoka/web.py _market_analysis) ;8// · enBref : 4 à 6 constats priorisés (marché, kilométrage vs âge, baisse9// de prix, même véhicule ailleurs, rappels, fraîcheur, Carfax) ;10// · ligneResume : « 2021 · 45 000 km · Automatique · Essence · VUS ».11// -----------------------------------------------------------------------------12import { Recall, VehicleDetail, fmtKm, fmtPrice } from "../api";13import { Tone, NBSP } from "./ui";1415export interface ComparaisonPrix {16 tone: "good" | "ok" | "high";17 pct: number; // écart signé en % vs médiane des comparables18 label: string;19 court: string;20 ref: number; // médiane ($)21 n: number;22 percentile: number; // % de comparables moins chers23}2425export function comparaisonPrix(v: VehicleDetail): ComparaisonPrix | null {26 const m = v.market;27 if (!m || v.price == null || m.n < 3) return null;28 const pct = Math.round(m.delta_pct);29 const tone = m.badge === "excellent" || m.badge === "good" ? "good" : m.badge === "fair" ? "ok" : "high";30 return {31 tone, pct, ref: m.median, n: m.n, percentile: m.percentile,32 label: m.label || (tone === "good" ? "Bon prix" : tone === "ok" ? "Dans le marché" : "Au-dessus du marché"),33 court: `${pct < 0 ? "↓" : pct > 0 ? "↑" : "≈"} ${Math.abs(pct)}${NBSP}% vs médiane`,34 };35}3637export interface Constat { tone: Tone; titre: string; detail: string; cle: string; }3839export function enBref(v: VehicleDetail, recalls: Recall[] | null): Constat[] {40 const out: Constat[] = [];41 const cmp = comparaisonPrix(v);4243 // 1. marché44 if (cmp) {45 out.push({ cle: "prix", tone: cmp.tone === "good" ? "good" : cmp.tone === "high" ? "warn" : "neutral",46 titre: cmp.tone === "good" ? `${cmp.label} — ${Math.abs(cmp.pct)}${NBSP}% sous la médiane` : cmp.tone === "high" ? `Prix au-dessus du marché (+${Math.abs(cmp.pct)}${NBSP}%)` : "Prix dans le marché",47 detail: `Médiane ${fmtPrice(cmp.ref)} sur ${cmp.n} ${v.make} ${v.model.split(" ")[0]} comparables · ${cmp.percentile}${NBSP}% sont moins chers` });48 } else if (v.price != null) {49 out.push({ cle: "prix", tone: "neutral", titre: "Prix non comparé", detail: "Pas assez de véhicules comparables en vente au Québec pour situer ce prix." });50 }5152 // 2. kilométrage vs âge (composante du KA Score)53 const km = v.ka_score?.parts.find((p) => p.key === "km");54 if (km && v.mileage_km != null && v.year) {55 const age = Math.max(1, new Date().getFullYear() - v.year);56 const parAn = Math.round(v.mileage_km / age / 1000) * 1000;57 out.push({ cle: "km", tone: km.score >= 70 ? "good" : km.score >= 45 ? "neutral" : "warn",58 titre: km.score >= 70 ? "Kilométrage bas pour l'âge" : km.score >= 45 ? "Kilométrage dans la norme" : "Kilométrage élevé pour l'âge",59 detail: `${fmtKm(v.mileage_km)} en ${age} an${age > 1 ? "s" : ""} ≈ ${parAn.toLocaleString("fr-CA")} km/an (référence ≈ 20 000 km/an)` });60 }6162 // 3. baisse de prix63 const hist = (v.price_history ?? []).filter((h) => h.price != null);64 if (hist.length >= 2 && hist[0].price! < hist[1].price!)65 out.push({ cle: "baisse", tone: "good", titre: "Prix en baisse", detail: `${fmtPrice(hist[1].price!)} → ${fmtPrice(hist[0].price!)} (−${(hist[1].price! - hist[0].price!).toLocaleString("fr-CA")} $) — observé par Auto-Ka` });6667 // 4. même véhicule ailleurs68 const dups = v.dup_sources ?? [];69 if (dups.length > 0) {70 const moins = dups.filter((d) => d.price != null && v.price != null && d.price < v.price);71 out.push({ cle: "dups", tone: moins.length ? "warn" : "neutral",72 titre: moins.length ? `Le même véhicule est affiché moins cher ailleurs` : `Le même véhicule est affiché sur ${dups.length} autre${dups.length > 1 ? "s" : ""} site${dups.length > 1 ? "s" : ""}`,73 detail: moins.length ? `${fmtPrice(Math.min(...moins.map((d) => d.price!)))} chez ${moins[0].dealer_name || moins[0].source}` : "Même NIV repéré (dédoublonnage Auto-Ka) — voir le dossier" });74 }7576 // 5. rappels77 if (recalls && recalls.length > 0)78 out.push({ cle: "rappels", tone: "warn", titre: `${recalls.length} rappel${recalls.length > 1 ? "s" : ""} Transports Canada pour ce modèle`,79 detail: `Le plus récent : ${recalls[0].date} · ${recalls[0].component} — vérifier auprès du concessionnaire s'ils ont été effectués` });80 else if (recalls && recalls.length === 0)81 out.push({ cle: "rappels", tone: "good", titre: "Aucun rappel Transports Canada répertorié", detail: `${v.make} ${v.model}${v.year ? ` ${v.year}` : ""} — base des rappels de sécurité` });8283 // 6. fraîcheur / temps en ligne84 const jours = v.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - v.first_seen) / 86400)) : null;85 if (jours != null && jours >= 45)86 out.push({ cle: "age", tone: "neutral", titre: `En vente depuis ${jours}${NBSP}jours`, detail: "Observé par Auto-Ka depuis la première synchronisation — marge de négociation possible" });87 else if (jours != null && jours <= 3)88 out.push({ cle: "age", tone: "info", titre: jours === 0 ? "Arrivé aujourd'hui" : `Nouvel arrivage (${jours}${NBSP}j)`, detail: "Repéré récemment par Auto-Ka chez le concessionnaire" });8990 // 7. Carfax91 if (v.carfax_url) out.push({ cle: "carfax", tone: "info", titre: "Rapport d'historique Carfax fourni", detail: "Lien du concessionnaire — accidents, réclamations, entretien" });9293 return out.slice(0, 6);94}9596export function ligneResume(v: VehicleDetail): string[] {97 const p: string[] = [];98 if (v.year) p.push(String(v.year));99 if (v.mileage_km != null) p.push(fmtKm(v.mileage_km));100 if (v.transmission) p.push(v.transmission);101 if (v.fuel) p.push(v.fuel);102 if (v.body_type) p.push(v.body_type);103 return p;104}105106/** Jours depuis la première observation. */107export const joursEnLigne = (v: VehicleDetail) => v.first_seen ? Math.max(0, Math.round((Date.now() / 1000 - v.first_seen) / 86400)) : null;108