SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
13 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
6.3 KB · 108 lines typescript
Raw Blame History
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