feat: AR-VINchecker v1.0 — VIN report generator with AI analysis
FastAPI server querying NHTSA/EPA APIs, generating PDF reports with risk scoring, and local Ollama AI analysis via DealAnalyzer. Security hardened: XSS fix, SSRF protection, CORS restricted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
output/
|
||||||
|
*.pdf
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# Claude Code Prompt — AR-VINChecker
|
||||||
|
## App de Reporte de Historial Vehicular por VIN (100% gratis, sin APIs de pago)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CONTEXTO DEL PROYECTO
|
||||||
|
|
||||||
|
Desarrollar una aplicación de escritorio Python/PySide6 llamada **AR-VinReport** que genere reportes PDF profesionales de historial vehicular usando exclusivamente APIs públicas y gratuitas del gobierno de EE.UU. La app es para uso personal/profesional en evaluación de vehículos en subastas (Copart, IAAI) antes de pujar.
|
||||||
|
|
||||||
|
**Ruta del proyecto:** `D:\Proyectos Software\AR-VinChecker\`
|
||||||
|
**Usuario:** Jairo — ingeniero naval en South Florida, evalúa vehículos en subastas para inversión.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LO QUE HACE LA APP
|
||||||
|
|
||||||
|
### Input (pantalla principal)
|
||||||
|
El usuario ingresa manualmente los datos que ve en el listing de la subasta:
|
||||||
|
- VIN (17 caracteres)
|
||||||
|
- Odómetro (millas)
|
||||||
|
- Daño primario (ej: "Normal Wear")
|
||||||
|
- Daño secundario (ej: "Damage History")
|
||||||
|
- Título (ej: "NY - Certificate of Title")
|
||||||
|
- Precio de bid actual ($)
|
||||||
|
- Nombre/URL de la subasta (opcional)
|
||||||
|
- URL de foto del vehículo (opcional, para incluir en el PDF)
|
||||||
|
|
||||||
|
### Procesamiento automático (al hacer click en "Generate Report")
|
||||||
|
La app consulta en paralelo:
|
||||||
|
|
||||||
|
1. **NHTSA VPIC API** — Decode del VIN:
|
||||||
|
- Make, Model, Year, Trim, Body Style
|
||||||
|
- Engine type, displacement, cylinders
|
||||||
|
- Drive type, transmission
|
||||||
|
- Plant country of manufacture
|
||||||
|
- URL: `https://vpic.nhtsa.dot.gov/api/vehicles/decodevin/{VIN}?format=json`
|
||||||
|
|
||||||
|
2. **NHTSA Recalls API** — Recalls activos:
|
||||||
|
- Lista de recalls por make/model/year
|
||||||
|
- Descripción del componente afectado
|
||||||
|
- Si está abierto o cerrado
|
||||||
|
- URL: `https://api.nhtsa.gov/recalls/recallsByVehicle?make={make}&model={model}&modelYear={year}`
|
||||||
|
|
||||||
|
3. **NHTSA Complaints API** — Quejas de propietarios:
|
||||||
|
- Total de quejas registradas
|
||||||
|
- Top 5 componentes con más quejas
|
||||||
|
- URL: `https://api.nhtsa.gov/complaints/complaintsByVehicle?make={make}&model={model}&modelYear={year}`
|
||||||
|
|
||||||
|
4. **NHTSA Investigations API** — Investigaciones de seguridad abiertas:
|
||||||
|
- URL: `https://api.nhtsa.gov/investigations/investigationsByVehicle?make={make}&model={model}&modelYear={year}`
|
||||||
|
|
||||||
|
5. **EPA FuelEconomy API** — Eficiencia y specs:
|
||||||
|
- MPG ciudad/highway, o kWh/100mi si es EléctricoF
|
||||||
|
- URL: `https://www.fueleconomy.gov/ws/rest/vehicle/menu/options?year={year}&make={make}&model={model}`
|
||||||
|
|
||||||
|
6. **Foto del vehículo** — Si el usuario pegó una URL, descargarla e incluirla en el PDF.
|
||||||
|
|
||||||
|
### Output — PDF Profesional
|
||||||
|
Generar un PDF de 1-2 páginas con:
|
||||||
|
|
||||||
|
**Header:**
|
||||||
|
- Logo "AR-VinReport" (texto estilizado si no hay imagen)
|
||||||
|
- Fecha y hora del reporte
|
||||||
|
- VIN consultado
|
||||||
|
|
||||||
|
**Sección 1 — Identificación del Vehículo**
|
||||||
|
- Foto del vehículo (si se proporcionó URL)
|
||||||
|
- Tabla: Year / Make / Model / Trim / Body
|
||||||
|
- Tabla: Engine / Transmission / Drive / Plant
|
||||||
|
|
||||||
|
**Sección 2 — Datos del Listing de Subasta**
|
||||||
|
- Tabla: Odómetro / Daño Primario / Daño Secundario / Título / Bid
|
||||||
|
|
||||||
|
**Sección 3 — Recalls NHTSA**
|
||||||
|
- Total de recalls encontrados
|
||||||
|
- Lista con: fecha, componente, descripción breve
|
||||||
|
- Badge verde "0 RECALLS" o rojo "X RECALLS ACTIVOS"
|
||||||
|
|
||||||
|
**Sección 4 — Quejas de Propietarios**
|
||||||
|
- Total de quejas
|
||||||
|
- Top 5 componentes problemáticos con conteo
|
||||||
|
- Gráfico de barras horizontal simple (matplotlib)
|
||||||
|
|
||||||
|
**Sección 5 — Investigaciones de Seguridad**
|
||||||
|
- Lista de investigaciones abiertas si las hay
|
||||||
|
|
||||||
|
**Sección 6 — Eficiencia EPA**
|
||||||
|
- MPG o kWh/100mi
|
||||||
|
- Estimado de costo anual de combustible/electricidad
|
||||||
|
|
||||||
|
**Sección 7 — Análisis de Riesgo (generado por la app)**
|
||||||
|
- Score de riesgo calculado: 0-100
|
||||||
|
- +20 puntos si título es Salvage
|
||||||
|
- +15 puntos por cada recall activo (max 30)
|
||||||
|
- +10 si daño secundario no es "None"
|
||||||
|
- +10 si quejas > 50
|
||||||
|
- -10 si odómetro < 60,000 millas
|
||||||
|
- -15 si título es Certificate of Title (limpio)
|
||||||
|
- Badge: BAJO RIESGO (verde) / RIESGO MEDIO (amarillo) / ALTO RIESGO (rojo)
|
||||||
|
|
||||||
|
**Footer:**
|
||||||
|
- "Datos de NHTSA.gov y EPA.gov — Información pública oficial del gobierno de EE.UU."
|
||||||
|
- "Este reporte no reemplaza un Carfax o inspección física"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STACK TÉCNICO
|
||||||
|
|
||||||
|
```
|
||||||
|
Python 3.11+
|
||||||
|
PySide6 — GUI de escritorio
|
||||||
|
requests — llamadas HTTP a las APIs
|
||||||
|
reportlab — generación de PDF profesional
|
||||||
|
matplotlib — gráfico de barras de quejas
|
||||||
|
Pillow (PIL) — manejo de imagen del vehículo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESTRUCTURA DEL PROYECTO
|
||||||
|
|
||||||
|
```
|
||||||
|
D:\Proyectos Software\AR-VinReport\
|
||||||
|
├── main.py ← Entry point, lanza la GUI
|
||||||
|
├── requirements.txt
|
||||||
|
├── src/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── gui/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── main_window.py ← Ventana principal PySide6
|
||||||
|
│ │ └── styles.py ← QSS stylesheet oscuro profesional
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── nhtsa.py ← Todas las llamadas a NHTSA
|
||||||
|
│ │ └── epa.py ← Llamadas a EPA FuelEconomy
|
||||||
|
│ ├── report/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ └── pdf_generator.py ← Generación del PDF con reportlab
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── vin_validator.py ← Validación de VIN (17 chars, checksum)
|
||||||
|
│ └── risk_score.py ← Cálculo del score de riesgo
|
||||||
|
├── assets/
|
||||||
|
│ └── logo.png ← Logo AR-VinReport (crear simple si no existe)
|
||||||
|
├── output/ ← PDFs generados se guardan aquí
|
||||||
|
└── docs/
|
||||||
|
├── LOGBOOK.md
|
||||||
|
├── LIBRARIES.md
|
||||||
|
└── modules/
|
||||||
|
├── gui_log.md
|
||||||
|
├── api_log.md
|
||||||
|
└── report_log.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GUI — DISEÑO DE LA VENTANA PRINCIPAL
|
||||||
|
|
||||||
|
Estilo oscuro profesional, consistente con las otras apps AR Suite (AR-ShipDesign, AR-Autopilot, etc.).
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ AR-VinReport v1.0 [dark theme] │
|
||||||
|
├──────────────────┬──────────────────────────────────┤
|
||||||
|
│ INPUT PANEL │ STATUS / PREVIEW │
|
||||||
|
│ │ │
|
||||||
|
│ VIN: [_______] │ ┌──────────────────────────┐ │
|
||||||
|
│ Odometer: [___] │ │ Waiting for VIN... │ │
|
||||||
|
│ Primary dmg:[_] │ │ │ │
|
||||||
|
│ Secondary: [__] │ │ [Progress steps show │ │
|
||||||
|
│ Title: [______] │ │ here during fetch] │ │
|
||||||
|
│ Bid ($): [____] │ │ │ │
|
||||||
|
│ Auction: [____] │ └──────────────────────────┘ │
|
||||||
|
│ Photo URL:[___] │ │
|
||||||
|
│ │ [GENERATE PDF REPORT] ← botón │
|
||||||
|
│ [FETCH DATA] │ [OPEN OUTPUT FOLDER] │
|
||||||
|
└──────────────────┴──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Al hacer click en FETCH DATA:
|
||||||
|
- Mostrar progress en el panel derecho paso a paso:
|
||||||
|
- ✅ VIN válido: 1FTVW1EL1NWG14881
|
||||||
|
- ⏳ Consultando NHTSA VPIC...
|
||||||
|
- ✅ Vehículo: 2022 Ford F-150 Lightning Pro
|
||||||
|
- ⏳ Consultando recalls...
|
||||||
|
- ✅ 8 recalls encontrados
|
||||||
|
- ⏳ Consultando quejas...
|
||||||
|
- ✅ 95 quejas registradas
|
||||||
|
- ⏳ Consultando EPA...
|
||||||
|
- ✅ Eficiencia: 2.0 kWh/mi
|
||||||
|
- ✅ Foto descargada
|
||||||
|
- 🟡 Score de riesgo: 35/100 — RIESGO MEDIO
|
||||||
|
- ✅ Reporte generado: output/VIN_20260703_143022.pdf
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## INSTRUCCIONES PARA CLAUDE CODE
|
||||||
|
|
||||||
|
1. **Leer este prompt completo antes de escribir código.**
|
||||||
|
|
||||||
|
2. **Crear toda la estructura de carpetas** del proyecto primero.
|
||||||
|
|
||||||
|
3. **Empezar por el módulo API** (`src/api/nhtsa.py`) y probarlo con el VIN de prueba `1FTVW1EL1NWG14881` (2022 Ford F-150 Lightning Pro).
|
||||||
|
|
||||||
|
4. **Verificar que las APIs respondan** antes de construir la GUI. Las APIs de NHTSA son HTTP GET simples sin autenticación.
|
||||||
|
|
||||||
|
5. **Construir la GUI** una vez confirmado que los datos llegan correctamente.
|
||||||
|
|
||||||
|
6. **Construir el PDF** con reportlab, no con weasyprint ni html2pdf — reportlab da más control sobre el layout.
|
||||||
|
|
||||||
|
7. **Al final de la sesión**, crear y poblar los archivos de documentación:
|
||||||
|
- `docs/LOGBOOK.md` — siguiendo el formato del app-organizer skill
|
||||||
|
- `docs/LIBRARIES.md` — documentando cada librería usada
|
||||||
|
- `docs/modules/api_log.md`, `gui_log.md`, `report_log.md`
|
||||||
|
|
||||||
|
8. **El PDF generado** debe guardarse en `output/` con nombre: `{VIN}_{YYYYMMDD}_{HHMMSS}.pdf`
|
||||||
|
|
||||||
|
9. **Manejo de errores**: si una API no responde, mostrar "No data available" en esa sección del PDF — nunca crashear.
|
||||||
|
|
||||||
|
10. **El validador de VIN** debe verificar:
|
||||||
|
- Exactamente 17 caracteres
|
||||||
|
- No contiene I, O, Q (caracteres prohibidos en VINs)
|
||||||
|
- Checksum digit válido (posición 9)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VIN DE PRUEBA
|
||||||
|
|
||||||
|
```
|
||||||
|
VIN: 1FTVW1EL1NWG14881
|
||||||
|
Odometer: 57,909 mi
|
||||||
|
Primary damage: Normal Wear
|
||||||
|
Secondary: Damage History
|
||||||
|
Title: NY - Certificate of Title
|
||||||
|
Bid: $4,100
|
||||||
|
Auction: Copart
|
||||||
|
```
|
||||||
|
|
||||||
|
Usar este VIN para todas las pruebas durante el desarrollo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITERIO DE ÉXITO
|
||||||
|
|
||||||
|
La sesión es exitosa cuando:
|
||||||
|
- [ ] `python main.py` lanza la ventana sin errores
|
||||||
|
- [ ] Al ingresar el VIN de prueba y hacer FETCH DATA, se obtienen datos reales de NHTSA
|
||||||
|
- [ ] El botón GENERATE PDF crea un archivo PDF en `output/`
|
||||||
|
- [ ] El PDF tiene: foto (si se dio URL), specs del vehículo, recalls, quejas, score de riesgo
|
||||||
|
- [ ] El PDF se abre correctamente en cualquier visor
|
||||||
|
- [ ] Documentación de logbook creada y poblada
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Prompt generado por Claude — AR Suite / Prisa Yachts LLC — Julio 2026*
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, Query, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from src.ai.ollama_analyzer import analyze_vehicle
|
||||||
|
from src.api.epa import fetch_epa_data
|
||||||
|
from src.api.nhtsa import decode_vin, fetch_complaints, fetch_investigations, fetch_recalls
|
||||||
|
from src.report.pdf import generate_pdf
|
||||||
|
from src.utils.risk import calculate_risk, compute_extended_fields
|
||||||
|
from src.utils.validator import validate_vin
|
||||||
|
|
||||||
|
Path("output").mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
_PRIVATE_NETS = [
|
||||||
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("172.16.0.0/12"),
|
||||||
|
ipaddress.ip_network("192.168.0.0/16"),
|
||||||
|
ipaddress.ip_network("127.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("169.254.0.0/16"),
|
||||||
|
ipaddress.ip_network("::1/128"),
|
||||||
|
ipaddress.ip_network("fc00::/7"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_photo_url(url: str) -> bool:
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
return False
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(host)
|
||||||
|
return not any(addr in net for net in _PRIVATE_NETS)
|
||||||
|
except ValueError:
|
||||||
|
blocked = ("localhost", "internal", "metadata", "169.254")
|
||||||
|
return not any(b in host.lower() for b in blocked)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="AR-VinReport", version="1.0.0")
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:3050", "http://127.0.0.1:3050"],
|
||||||
|
allow_methods=["GET", "POST"],
|
||||||
|
allow_headers=["Content-Type"],
|
||||||
|
)
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
app.mount("/output", StaticFiles(directory="output"), name="output")
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
_executor = ThreadPoolExecutor(max_workers=4)
|
||||||
|
_store: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pages ─────────────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/")
|
||||||
|
async def index(request: Request):
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSE fetch endpoint ────────────────────────────────────────────────────────
|
||||||
|
@app.get("/api/fetch")
|
||||||
|
async def fetch(
|
||||||
|
vin: str = Query(...),
|
||||||
|
odometer: Optional[str] = Query(None),
|
||||||
|
primary_damage: Optional[str] = Query(None),
|
||||||
|
secondary_damage: Optional[str] = Query(None),
|
||||||
|
title: Optional[str] = Query(None),
|
||||||
|
bid: Optional[str] = Query(None),
|
||||||
|
auction: Optional[str] = Query(None),
|
||||||
|
photo_url: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
async def stream():
|
||||||
|
def evt(step: str, status: str = "progress", **extra) -> str:
|
||||||
|
payload = {"step": step, "status": status, **extra}
|
||||||
|
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1 — Validate VIN
|
||||||
|
vin_clean = vin.strip().upper()
|
||||||
|
ok, err = validate_vin(vin_clean)
|
||||||
|
if not ok:
|
||||||
|
yield evt(f"❌ VIN inválido: {err}", "error")
|
||||||
|
return
|
||||||
|
yield evt(f"✅ VIN válido: {vin_clean}", "success")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# 2 — Decode VIN
|
||||||
|
yield evt("⏳ Consultando NHTSA VPIC...", "progress")
|
||||||
|
vpic = await decode_vin(vin_clean)
|
||||||
|
if not vpic:
|
||||||
|
yield evt("⚠️ VPIC sin datos — continuando de todas formas", "warning")
|
||||||
|
make = vpic.get("Make", "")
|
||||||
|
model = vpic.get("Model", "")
|
||||||
|
year = vpic.get("ModelYear", "")
|
||||||
|
yield evt(f"✅ Vehículo: {year} {make} {model}", "success")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# 3 — Recalls + Complaints + Investigations (paralelo)
|
||||||
|
yield evt("⏳ Consultando recalls, quejas e investigaciones...", "progress")
|
||||||
|
recalls, complaints, invests = await asyncio.gather(
|
||||||
|
fetch_recalls(make, model, year),
|
||||||
|
fetch_complaints(make, model, year),
|
||||||
|
fetch_investigations(make, model, year),
|
||||||
|
)
|
||||||
|
yield evt(
|
||||||
|
f"✅ {len(recalls)} recalls · {len(complaints)} quejas · {len(invests)} investigaciones",
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# 4 — EPA
|
||||||
|
yield evt("⏳ Consultando EPA FuelEconomy...", "progress")
|
||||||
|
epa = await fetch_epa_data(year, make, model)
|
||||||
|
yield evt(f"✅ Eficiencia: {epa.get('mpg_combined', 'N/A')}", "success")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# 5 — Photo
|
||||||
|
photo_bytes = None
|
||||||
|
if photo_url and photo_url.strip():
|
||||||
|
yield evt("⏳ Descargando foto del vehículo...", "progress")
|
||||||
|
if not _is_safe_photo_url(photo_url.strip()):
|
||||||
|
yield evt("⚠️ URL de foto no permitida (dominio bloqueado)", "warning")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.get(photo_url.strip())
|
||||||
|
if r.status_code == 200:
|
||||||
|
photo_bytes = r.content
|
||||||
|
yield evt("✅ Foto descargada", "success")
|
||||||
|
else:
|
||||||
|
yield evt("⚠️ No se pudo descargar la foto (HTTP error)", "warning")
|
||||||
|
except Exception:
|
||||||
|
yield evt("⚠️ Error al descargar la foto", "warning")
|
||||||
|
|
||||||
|
# 6 — Risk score + extended fields
|
||||||
|
risk = calculate_risk(
|
||||||
|
title=title or "",
|
||||||
|
recalls=recalls,
|
||||||
|
secondary_damage=secondary_damage or "",
|
||||||
|
complaints=complaints,
|
||||||
|
odometer=odometer or "0",
|
||||||
|
)
|
||||||
|
extended = compute_extended_fields(
|
||||||
|
title=title or "",
|
||||||
|
recalls=recalls,
|
||||||
|
complaints=complaints,
|
||||||
|
odometer=odometer or "0",
|
||||||
|
secondary_damage=secondary_damage or "",
|
||||||
|
)
|
||||||
|
score = risk["score"]
|
||||||
|
level = risk["level"]
|
||||||
|
emoji = risk["emoji"]
|
||||||
|
yield evt(f"{emoji} Score: {score}/100 — {level}", "success")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# 7 — Ollama AI analysis
|
||||||
|
yield evt("⏳ Consultando IA local (DealAnalyzer)...", "progress")
|
||||||
|
vehicle_data_for_ai = {
|
||||||
|
"vin": vin_clean,
|
||||||
|
"odometer": odometer or "—",
|
||||||
|
"primary_damage": primary_damage or "—",
|
||||||
|
"secondary_damage": secondary_damage or "—",
|
||||||
|
"title": title or "—",
|
||||||
|
"bid": bid or "—",
|
||||||
|
"vpic": vpic,
|
||||||
|
"risk": risk,
|
||||||
|
"critical_recall_count": extended["critical_recall_count"],
|
||||||
|
"engine_complaints": extended["engine_complaints"],
|
||||||
|
"transmission_complaints": extended["transmission_complaints"],
|
||||||
|
"alerts": extended["alerts"],
|
||||||
|
}
|
||||||
|
ai_analysis = await analyze_vehicle(vehicle_data_for_ai)
|
||||||
|
if ai_analysis:
|
||||||
|
yield evt("✅ Análisis IA generado", "success")
|
||||||
|
else:
|
||||||
|
yield evt("⚠️ Ollama no disponible — sección IA omitida", "warning")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# Store for PDF generation
|
||||||
|
_store[vin_clean] = {
|
||||||
|
"vin": vin_clean,
|
||||||
|
"odometer": odometer or "—",
|
||||||
|
"primary_damage": primary_damage or "—",
|
||||||
|
"secondary_damage": secondary_damage or "—",
|
||||||
|
"title": title or "—",
|
||||||
|
"bid": bid or "—",
|
||||||
|
"auction": auction or "—",
|
||||||
|
"photo_url": photo_url or "",
|
||||||
|
"photo_bytes": base64.b64encode(photo_bytes).decode() if photo_bytes else None,
|
||||||
|
"vpic": vpic,
|
||||||
|
"recalls": recalls,
|
||||||
|
"complaints": complaints,
|
||||||
|
"investigations": invests,
|
||||||
|
"epa": epa,
|
||||||
|
"risk": risk,
|
||||||
|
"ai_analysis": ai_analysis,
|
||||||
|
**extended,
|
||||||
|
}
|
||||||
|
|
||||||
|
vehicle_label = f"{year} {make} {model}".strip() or vin_clean
|
||||||
|
yield evt(
|
||||||
|
"✅ Datos listos — puedes generar el reporte PDF",
|
||||||
|
"done",
|
||||||
|
vin=vin_clean,
|
||||||
|
vehicle=vehicle_label,
|
||||||
|
risk=risk,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
yield evt("❌ Error inesperado procesando la solicitud", "error")
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Generate PDF ───────────────────────────────────────────────────────────────
|
||||||
|
@app.post("/api/generate")
|
||||||
|
async def generate(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
vin = (body.get("vin") or "").strip().upper()
|
||||||
|
|
||||||
|
if vin not in _store:
|
||||||
|
return {"error": "No hay datos para este VIN. Haz fetch primero."}
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
filename = await loop.run_in_executor(_executor, generate_pdf, _store[vin])
|
||||||
|
except Exception:
|
||||||
|
return {"error": "Error generando el PDF"}
|
||||||
|
|
||||||
|
return {"filename": filename, "url": f"/output/{filename}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Open file locally (Windows only) ──────────────────────────────────────────
|
||||||
|
@app.post("/api/open")
|
||||||
|
async def open_file(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
filename = (body.get("filename") or "").replace("..", "").replace("/", "").replace("\\", "")
|
||||||
|
path = Path("output") / filename
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return {"error": "Archivo no encontrado"}
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return {"error": "Auto-open solo disponible en Windows"}
|
||||||
|
|
||||||
|
os.startfile(str(path.resolve()))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Entry point ────────────────────────────────────────────────────────────────
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=3050, reload=True)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
httpx==0.25.2
|
||||||
|
reportlab==4.0.7
|
||||||
|
matplotlib==3.8.2
|
||||||
|
Pillow==10.1.0
|
||||||
|
jinja2==3.1.2
|
||||||
|
python-multipart==0.0.6
|
||||||
|
aiofiles==23.2.1
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import httpx
|
||||||
|
|
||||||
|
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||||||
|
PRIMARY_MODEL = "DealAnalyzer"
|
||||||
|
FALLBACK_MODEL = "qwen2.5:14b"
|
||||||
|
|
||||||
|
_OLLAMA_OPTIONS = {"temperature": 0.3, "num_predict": 250}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_prompt(data: dict) -> str:
|
||||||
|
# Sanitize string values to prevent prompt injection via control sequences
|
||||||
|
def s(val) -> str:
|
||||||
|
if val is None:
|
||||||
|
return "N/A"
|
||||||
|
cleaned = str(val).replace("\n", " ").replace("\r", " ").strip()
|
||||||
|
return cleaned[:200] # cap length per field
|
||||||
|
|
||||||
|
vpic = data.get("vpic", {})
|
||||||
|
risk = data.get("risk", {})
|
||||||
|
alerts = data.get("alerts", [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
"Eres un experto en evaluación de vehículos usados en subastas americanas (Copart, IAAI).\n"
|
||||||
|
"Analiza estos datos y genera un resumen ejecutivo en español de máximo 150 palabras.\n"
|
||||||
|
"Sé directo y específico. Termina con una recomendación clara: COMPRAR, INVESTIGAR MÁS, o EVITAR.\n\n"
|
||||||
|
"DATOS DEL VEHÍCULO:\n"
|
||||||
|
f"- VIN: {s(data.get('vin'))}\n"
|
||||||
|
f"- Vehículo: {s(vpic.get('ModelYear'))} {s(vpic.get('Make'))} {s(vpic.get('Model'))} {s(vpic.get('Trim'))}\n"
|
||||||
|
f"- Odómetro: {s(data.get('odometer'))} millas\n"
|
||||||
|
f"- Daño primario: {s(data.get('primary_damage'))}\n"
|
||||||
|
f"- Daño secundario: {s(data.get('secondary_damage'))}\n"
|
||||||
|
f"- Tipo de título: {s(data.get('title'))}\n"
|
||||||
|
f"- Bid actual: ${s(data.get('bid'))}\n"
|
||||||
|
f"- Recalls activos NHTSA: {s(risk.get('recall_count'))}\n"
|
||||||
|
f"- Recalls críticos: {s(data.get('critical_recall_count'))}\n"
|
||||||
|
f"- Total quejas propietarios: {s(risk.get('complaint_count'))}\n"
|
||||||
|
f"- Quejas de motor: {s(data.get('engine_complaints'))}\n"
|
||||||
|
f"- Quejas de transmisión: {s(data.get('transmission_complaints'))}\n"
|
||||||
|
f"- Score de riesgo calculado: {s(risk.get('score'))}/100\n"
|
||||||
|
f"- Alertas detectadas: {s('; '.join(alerts) if alerts else 'Ninguna')}\n\n"
|
||||||
|
"RESUMEN EJECUTIVO:\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_vehicle(data: dict) -> str | None:
|
||||||
|
prompt = _build_prompt(data)
|
||||||
|
|
||||||
|
# Try PRIMARY with short timeout first, then FALLBACK with full timeout
|
||||||
|
schedule = [
|
||||||
|
(PRIMARY_MODEL, 10),
|
||||||
|
(FALLBACK_MODEL, 30),
|
||||||
|
]
|
||||||
|
|
||||||
|
for model, timeout in schedule:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
OLLAMA_URL,
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
"options": _OLLAMA_OPTIONS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json().get("response", "").strip()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import httpx
|
||||||
|
|
||||||
|
_EPA_BASE = "https://www.fueleconomy.gov/ws/rest"
|
||||||
|
_HEADERS = {"Accept": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_epa_data(year: str, make: str, model: str) -> dict:
|
||||||
|
empty = {"mpg_combined": "N/A", "mpg_city": "N/A", "mpg_highway": "N/A",
|
||||||
|
"fuel_type": "N/A", "annual_cost": "N/A"}
|
||||||
|
try:
|
||||||
|
url = f"{_EPA_BASE}/vehicle/menu/options"
|
||||||
|
params = {"year": year, "make": make.title(), "model": model}
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(url, params=params, headers=_HEADERS)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return empty
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
items = data.get("menuItem", [])
|
||||||
|
if not items:
|
||||||
|
return empty
|
||||||
|
if isinstance(items, dict):
|
||||||
|
items = [items]
|
||||||
|
|
||||||
|
vehicle_id = items[0].get("value")
|
||||||
|
if not vehicle_id:
|
||||||
|
return empty
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp2 = await client.get(f"{_EPA_BASE}/vehicle/{vehicle_id}", headers=_HEADERS)
|
||||||
|
if resp2.status_code != 200:
|
||||||
|
return empty
|
||||||
|
v = resp2.json()
|
||||||
|
|
||||||
|
fuel = v.get("fuelType1", v.get("fuelType", "Gasoline"))
|
||||||
|
is_electric = "Electricity" in (fuel or "")
|
||||||
|
|
||||||
|
if is_electric:
|
||||||
|
return {
|
||||||
|
"mpg_combined": f"{v.get('combA', v.get('comb08', 'N/A'))} MPGe",
|
||||||
|
"mpg_city": f"{v.get('cityA', v.get('city08', 'N/A'))} MPGe city",
|
||||||
|
"mpg_highway": f"{v.get('highwayA', v.get('highway08', 'N/A'))} MPGe hwy",
|
||||||
|
"range_miles": str(v.get("range", v.get("rangeA", "N/A"))),
|
||||||
|
"fuel_type": fuel,
|
||||||
|
"annual_cost": f"${v.get('annually1', v.get('you1', 'N/A'))}",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mpg_combined": f"{v.get('comb08', 'N/A')} MPG",
|
||||||
|
"mpg_city": f"{v.get('city08', 'N/A')} MPG city",
|
||||||
|
"mpg_highway": f"{v.get('highway08', 'N/A')} MPG hwy",
|
||||||
|
"fuel_type": fuel,
|
||||||
|
"annual_cost": f"${v.get('annually', v.get('you1', 'N/A'))}",
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return empty
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import httpx
|
||||||
|
|
||||||
|
_VPIC_BASE = "https://vpic.nhtsa.dot.gov/api/vehicles"
|
||||||
|
_NHTSA_BASE = "https://api.nhtsa.gov"
|
||||||
|
|
||||||
|
_VPIC_FIELDS = {
|
||||||
|
"Make", "Model", "Model Year", "Trim", "Body Class",
|
||||||
|
"Displacement (L)", "Engine Number of Cylinders", "Fuel Type - Primary",
|
||||||
|
"Drive Type", "Transmission Style", "Plant Country",
|
||||||
|
"Electrification Level", "Engine Model",
|
||||||
|
}
|
||||||
|
|
||||||
|
_FIELD_MAP = {
|
||||||
|
"Make": "Make",
|
||||||
|
"Model": "Model",
|
||||||
|
"Model Year": "ModelYear",
|
||||||
|
"Trim": "Trim",
|
||||||
|
"Body Class": "BodyClass",
|
||||||
|
"Displacement (L)": "DisplacementL",
|
||||||
|
"Engine Number of Cylinders": "Cylinders",
|
||||||
|
"Fuel Type - Primary": "FuelType",
|
||||||
|
"Drive Type": "DriveType",
|
||||||
|
"Transmission Style": "Transmission",
|
||||||
|
"Plant Country": "PlantCountry",
|
||||||
|
"Electrification Level": "EVLevel",
|
||||||
|
"Engine Model": "EngineModel",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def decode_vin(vin: str) -> dict:
|
||||||
|
url = f"{_VPIC_BASE}/decodevin/{vin}?format=json"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result: dict = {}
|
||||||
|
for item in data.get("Results", []):
|
||||||
|
var = item.get("Variable", "")
|
||||||
|
val = (item.get("Value") or "").strip()
|
||||||
|
if var in _FIELD_MAP and val and val not in ("Not Applicable", "null", "None"):
|
||||||
|
result[_FIELD_MAP[var]] = val
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_recalls(make: str, model: str, year: str) -> list:
|
||||||
|
url = f"{_NHTSA_BASE}/recalls/recallsByVehicle"
|
||||||
|
params = {"make": make, "model": model, "modelYear": year}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(url, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("results", [])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_complaints(make: str, model: str, year: str) -> list:
|
||||||
|
url = f"{_NHTSA_BASE}/complaints/complaintsByVehicle"
|
||||||
|
params = {"make": make, "model": model, "modelYear": year}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(url, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("results", [])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_investigations(make: str, model: str, year: str) -> list:
|
||||||
|
url = f"{_NHTSA_BASE}/investigations/investigationsByVehicle"
|
||||||
|
params = {"make": make, "model": model, "modelYear": year}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(url, params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("results", [])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import io
|
||||||
|
import base64
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
from reportlab.lib.pagesizes import letter
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.colors import HexColor, white, black
|
||||||
|
from reportlab.lib.units import inch
|
||||||
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||||||
|
from reportlab.platypus import (
|
||||||
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
||||||
|
Image, HRFlowable, KeepTogether,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Palette ──────────────────────────────────────────────────────────────────
|
||||||
|
NAVY = HexColor("#1a3a5c")
|
||||||
|
NAVY2 = HexColor("#2c3e50")
|
||||||
|
SLATE = HexColor("#34495e")
|
||||||
|
LGRAY = HexColor("#f4f6f8")
|
||||||
|
MGRAY = HexColor("#dee2e6")
|
||||||
|
DGRAY = HexColor("#6c757d")
|
||||||
|
C_GREEN = HexColor("#27ae60")
|
||||||
|
C_YELL = HexColor("#f39c12")
|
||||||
|
C_RED = HexColor("#e74c3c")
|
||||||
|
C_WHITE = white
|
||||||
|
C_BLACK = black
|
||||||
|
|
||||||
|
W = letter[0] - inch # usable width
|
||||||
|
|
||||||
|
# ── Styles ───────────────────────────────────────────────────────────────────
|
||||||
|
_base = getSampleStyleSheet()
|
||||||
|
|
||||||
|
STYLE_LOGO = ParagraphStyle("logo", fontSize=20, fontName="Helvetica-Bold",
|
||||||
|
textColor=C_WHITE, alignment=TA_LEFT)
|
||||||
|
STYLE_SMALL = ParagraphStyle("small", fontSize=7, fontName="Helvetica",
|
||||||
|
textColor=C_WHITE, alignment=TA_RIGHT)
|
||||||
|
STYLE_VIN = ParagraphStyle("vin", fontSize=8, fontName="Helvetica-Bold",
|
||||||
|
textColor=HexColor("#aed6f1"), alignment=TA_RIGHT)
|
||||||
|
STYLE_SEC = ParagraphStyle("sec", fontSize=10, fontName="Helvetica-Bold",
|
||||||
|
textColor=C_WHITE, spaceBefore=8, spaceAfter=4)
|
||||||
|
STYLE_BODY = ParagraphStyle("body", fontSize=8, fontName="Helvetica",
|
||||||
|
textColor=NAVY2, spaceAfter=2)
|
||||||
|
STYLE_FOOT = ParagraphStyle("foot", fontSize=6.5, fontName="Helvetica",
|
||||||
|
textColor=DGRAY, alignment=TA_CENTER)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
def _section(title: str) -> list:
|
||||||
|
header = Table(
|
||||||
|
[[Paragraph(title, STYLE_SEC)]],
|
||||||
|
colWidths=[W],
|
||||||
|
)
|
||||||
|
header.setStyle(TableStyle([
|
||||||
|
("BACKGROUND", (0, 0), (-1, -1), NAVY),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||||
|
]))
|
||||||
|
return [Spacer(1, 6), header, Spacer(1, 4)]
|
||||||
|
|
||||||
|
|
||||||
|
def _table(rows: list, col_widths: list, has_header: bool = True) -> Table:
|
||||||
|
t = Table(rows, colWidths=col_widths, repeatRows=1 if has_header else 0)
|
||||||
|
style = [
|
||||||
|
("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
|
||||||
|
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||||
|
("GRID", (0, 0), (-1, -1), 0.4, MGRAY),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||||
|
("ROWBACKGROUNDS", (0, 1 if has_header else 0), (-1, -1), [LGRAY, C_WHITE]),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
]
|
||||||
|
if has_header:
|
||||||
|
style += [
|
||||||
|
("BACKGROUND", (0, 0), (-1, 0), NAVY2),
|
||||||
|
("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE),
|
||||||
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||||
|
]
|
||||||
|
t.setStyle(TableStyle(style))
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _badge(text: str, color: str) -> Table:
|
||||||
|
bg = C_GREEN if color == "green" else (C_YELL if color == "yellow" else C_RED)
|
||||||
|
p = ParagraphStyle("badge", fontSize=11, fontName="Helvetica-Bold",
|
||||||
|
textColor=C_WHITE, alignment=TA_CENTER)
|
||||||
|
t = Table([[Paragraph(text, p)]], colWidths=[W])
|
||||||
|
t.setStyle(TableStyle([
|
||||||
|
("BACKGROUND", (0, 0), (-1, -1), bg),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 8),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||||
|
("ROUNDEDCORNERS", [4]),
|
||||||
|
]))
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _complaints_chart(complaints: list) -> io.BytesIO | None:
|
||||||
|
if not complaints:
|
||||||
|
return None
|
||||||
|
|
||||||
|
components: list[str] = []
|
||||||
|
for c in complaints:
|
||||||
|
raw = c.get("components", c.get("component", "")) or ""
|
||||||
|
for part in raw.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part:
|
||||||
|
components.append(part[:40])
|
||||||
|
|
||||||
|
if not components:
|
||||||
|
return None
|
||||||
|
|
||||||
|
top5 = Counter(components).most_common(5)
|
||||||
|
labels = [item[0] for item in top5]
|
||||||
|
values = [item[1] for item in top5]
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(6.5, 2.2))
|
||||||
|
fig.patch.set_facecolor("white")
|
||||||
|
ax.set_facecolor("#f8f9fa")
|
||||||
|
|
||||||
|
bars = ax.barh(labels, values, color="#2980b9", edgecolor="none", height=0.55)
|
||||||
|
ax.set_xlabel("Quejas", fontsize=7, color="#555")
|
||||||
|
ax.tick_params(axis="both", labelsize=7, colors="#333")
|
||||||
|
for sp in ("top", "right"):
|
||||||
|
ax.spines[sp].set_visible(False)
|
||||||
|
for bar, val in zip(bars, values):
|
||||||
|
ax.text(bar.get_width() + 0.2, bar.get_y() + bar.get_height() / 2,
|
||||||
|
str(val), va="center", fontsize=7, color="#333")
|
||||||
|
|
||||||
|
plt.tight_layout(pad=0.4)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
plt.savefig(buf, format="png", dpi=130, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
buf.seek(0)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main generator ────────────────────────────────────────────────────────────
|
||||||
|
def generate_pdf(data: dict) -> str:
|
||||||
|
vin = data["vin"]
|
||||||
|
vpic = data.get("vpic", {})
|
||||||
|
recalls = data.get("recalls", [])
|
||||||
|
complaints = data.get("complaints", [])
|
||||||
|
invests = data.get("investigations", [])
|
||||||
|
epa = data.get("epa", {})
|
||||||
|
risk = data.get("risk", {})
|
||||||
|
|
||||||
|
make = vpic.get("Make", "Unknown")
|
||||||
|
model = vpic.get("Model", "Unknown")
|
||||||
|
year = vpic.get("ModelYear", "Unknown")
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"{vin}_{timestamp}.pdf"
|
||||||
|
out_path = Path("output") / filename
|
||||||
|
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
str(out_path),
|
||||||
|
pagesize=letter,
|
||||||
|
rightMargin=0.5 * inch,
|
||||||
|
leftMargin=0.5 * inch,
|
||||||
|
topMargin=0.5 * inch,
|
||||||
|
bottomMargin=0.5 * inch,
|
||||||
|
)
|
||||||
|
|
||||||
|
story: list = []
|
||||||
|
|
||||||
|
# ── Header ────────────────────────────────────────────────────────────────
|
||||||
|
now_str = datetime.now().strftime("%B %d, %Y — %H:%M")
|
||||||
|
header = Table(
|
||||||
|
[[
|
||||||
|
Paragraph("🚗 AR-VinReport", STYLE_LOGO),
|
||||||
|
Table(
|
||||||
|
[[Paragraph(now_str, STYLE_SMALL)],
|
||||||
|
[Paragraph(f"VIN: {vin}", STYLE_VIN)]],
|
||||||
|
colWidths=[3 * inch],
|
||||||
|
),
|
||||||
|
]],
|
||||||
|
colWidths=[W - 3 * inch, 3 * inch],
|
||||||
|
)
|
||||||
|
header.setStyle(TableStyle([
|
||||||
|
("BACKGROUND", (0, 0), (-1, -1), NAVY),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
|
]))
|
||||||
|
story.append(header)
|
||||||
|
story.append(Spacer(1, 8))
|
||||||
|
|
||||||
|
# ── Vehicle photo + ID table ───────────────────────────────────────────────
|
||||||
|
story += _section("1 — IDENTIFICACIÓN DEL VEHÍCULO")
|
||||||
|
|
||||||
|
photo_elem = None
|
||||||
|
photo_b64 = data.get("photo_bytes")
|
||||||
|
if photo_b64:
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(photo_b64)
|
||||||
|
img = PILImage.open(io.BytesIO(raw))
|
||||||
|
img.thumbnail((220, 160), PILImage.LANCZOS)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
buf.seek(0)
|
||||||
|
photo_elem = Image(buf, width=220, height=160)
|
||||||
|
except Exception:
|
||||||
|
photo_elem = None
|
||||||
|
|
||||||
|
id_rows = [
|
||||||
|
["Campo", "Valor"],
|
||||||
|
["Año / Make / Model", f"{year} {make} {model}"],
|
||||||
|
["Trim", vpic.get("Trim", "—")],
|
||||||
|
["Carrocería", vpic.get("BodyClass", "—")],
|
||||||
|
["Motor", f"{vpic.get('Cylinders','—')} cil. — {vpic.get('DisplacementL','—')} L"],
|
||||||
|
["Combustible", vpic.get("FuelType", "—")],
|
||||||
|
["Tracción", vpic.get("DriveType", "—")],
|
||||||
|
["Transmisión", vpic.get("Transmission", "—")],
|
||||||
|
["Planta", vpic.get("PlantCountry", "—")],
|
||||||
|
["EV Level", vpic.get("EVLevel", "—")],
|
||||||
|
]
|
||||||
|
|
||||||
|
if photo_elem:
|
||||||
|
id_tbl = _table(id_rows, [1.4 * inch, 3.4 * inch])
|
||||||
|
combo = Table(
|
||||||
|
[[photo_elem, id_tbl]],
|
||||||
|
colWidths=[2.5 * inch, W - 2.5 * inch],
|
||||||
|
)
|
||||||
|
combo.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||||
|
("LEFTPADDING", (1, 0), (1, 0), 8)]))
|
||||||
|
story.append(combo)
|
||||||
|
else:
|
||||||
|
story.append(_table(id_rows, [2 * inch, W - 2 * inch]))
|
||||||
|
|
||||||
|
# ── Auction listing ────────────────────────────────────────────────────────
|
||||||
|
story += _section("2 — DATOS DEL LISTING DE SUBASTA")
|
||||||
|
|
||||||
|
auction_rows = [
|
||||||
|
["Campo", "Valor"],
|
||||||
|
["Odómetro", data.get("odometer", "—") + " mi"],
|
||||||
|
["Daño Primario", data.get("primary_damage", "—")],
|
||||||
|
["Daño Secundario", data.get("secondary_damage", "—")],
|
||||||
|
["Título", data.get("title", "—")],
|
||||||
|
["Bid actual", f"${data.get('bid', '—')}"],
|
||||||
|
["Subasta", data.get("auction", "—")],
|
||||||
|
]
|
||||||
|
story.append(_table(auction_rows, [2 * inch, W - 2 * inch]))
|
||||||
|
|
||||||
|
# ── Recalls ───────────────────────────────────────────────────────────────
|
||||||
|
story += _section("3 — RECALLS NHTSA")
|
||||||
|
|
||||||
|
recall_badge_text = f"✅ 0 RECALLS" if not recalls else f"⚠️ {len(recalls)} RECALL{'S' if len(recalls)>1 else ''} ACTIVO{'S' if len(recalls)>1 else ''}"
|
||||||
|
badge_color = "green" if not recalls else "red"
|
||||||
|
story.append(_badge(recall_badge_text, badge_color))
|
||||||
|
story.append(Spacer(1, 6))
|
||||||
|
|
||||||
|
if recalls:
|
||||||
|
r_rows = [["#", "Campaña", "Componente", "Descripción breve"]]
|
||||||
|
for i, r in enumerate(recalls[:20], 1):
|
||||||
|
comp = (r.get("Component") or r.get("component") or "—")[:35]
|
||||||
|
summary = (r.get("Summary") or r.get("summary") or "—")[:80]
|
||||||
|
campaign = r.get("NHTSACampaignNumber") or r.get("campaignNumber") or "—"
|
||||||
|
r_rows.append([str(i), campaign, comp, summary])
|
||||||
|
story.append(_table(r_rows, [0.3*inch, 1.1*inch, 1.8*inch, W-3.2*inch]))
|
||||||
|
|
||||||
|
# ── Complaints ────────────────────────────────────────────────────────────
|
||||||
|
story += _section("4 — QUEJAS DE PROPIETARIOS")
|
||||||
|
|
||||||
|
story.append(Paragraph(
|
||||||
|
f"Total de quejas registradas: <b>{len(complaints)}</b>",
|
||||||
|
STYLE_BODY,
|
||||||
|
))
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
|
||||||
|
chart_buf = _complaints_chart(complaints)
|
||||||
|
if chart_buf:
|
||||||
|
story.append(Image(chart_buf, width=W, height=1.8 * inch))
|
||||||
|
elif complaints:
|
||||||
|
story.append(Paragraph("(Sin datos de componentes disponibles)", STYLE_BODY))
|
||||||
|
else:
|
||||||
|
story.append(Paragraph("Sin quejas registradas.", STYLE_BODY))
|
||||||
|
|
||||||
|
# ── Investigations ────────────────────────────────────────────────────────
|
||||||
|
story += _section("5 — INVESTIGACIONES DE SEGURIDAD")
|
||||||
|
|
||||||
|
if invests:
|
||||||
|
inv_rows = [["Número", "Asunto", "Estado"]]
|
||||||
|
for inv in invests[:10]:
|
||||||
|
num = inv.get("InvestigationNumber") or inv.get("investigationNumber") or "—"
|
||||||
|
subj = (inv.get("Subject") or inv.get("subject") or "—")[:60]
|
||||||
|
closed = inv.get("ClosingDate") or inv.get("closingDate")
|
||||||
|
status = "Cerrada" if closed else "Abierta"
|
||||||
|
inv_rows.append([num, subj, status])
|
||||||
|
story.append(_table(inv_rows, [1.2*inch, W-2.4*inch, 1.2*inch]))
|
||||||
|
else:
|
||||||
|
story.append(Paragraph("Sin investigaciones activas.", STYLE_BODY))
|
||||||
|
|
||||||
|
# ── EPA ───────────────────────────────────────────────────────────────────
|
||||||
|
story += _section("6 — EFICIENCIA EPA")
|
||||||
|
|
||||||
|
epa_rows = [
|
||||||
|
["Métrica", "Valor"],
|
||||||
|
["Combustible", epa.get("fuel_type", "N/A")],
|
||||||
|
["Eficiencia", epa.get("mpg_combined", "N/A")],
|
||||||
|
["Ciudad", epa.get("mpg_city", "N/A")],
|
||||||
|
["Autopista", epa.get("mpg_highway", "N/A")],
|
||||||
|
["Costo anual est.", epa.get("annual_cost", "N/A")],
|
||||||
|
]
|
||||||
|
if "range_miles" in epa:
|
||||||
|
epa_rows.append(["Autonomía eléctrica", f"{epa['range_miles']} mi"])
|
||||||
|
story.append(_table(epa_rows, [2 * inch, W - 2 * inch]))
|
||||||
|
|
||||||
|
# ── Risk Score ────────────────────────────────────────────────────────────
|
||||||
|
story += _section("7 — ANÁLISIS DE RIESGO")
|
||||||
|
|
||||||
|
score = risk.get("score", 0)
|
||||||
|
level = risk.get("level", "N/A")
|
||||||
|
color = risk.get("color", "green")
|
||||||
|
story.append(_badge(f"{risk.get('emoji','')} SCORE: {score}/100 — {level}", color))
|
||||||
|
story.append(Spacer(1, 6))
|
||||||
|
|
||||||
|
factors = risk.get("factors", [])
|
||||||
|
if factors:
|
||||||
|
f_rows = [["Factor", "Puntos"]]
|
||||||
|
for f in factors:
|
||||||
|
sign = "+" if f["dir"] == "+" else "−"
|
||||||
|
f_rows.append([f["label"], f"{sign}{f['points']}"])
|
||||||
|
story.append(_table(f_rows, [W - 1.2 * inch, 1.2 * inch]))
|
||||||
|
|
||||||
|
# ── AI Analysis ───────────────────────────────────────────────────────────
|
||||||
|
story += _section("8 — ANÁLISIS INTELIGENTE (IA Local)")
|
||||||
|
|
||||||
|
ai_text = data.get("ai_analysis")
|
||||||
|
|
||||||
|
STYLE_AI_LABEL = ParagraphStyle(
|
||||||
|
"ai_label", fontSize=7, fontName="Helvetica-Oblique",
|
||||||
|
textColor=DGRAY, spaceAfter=6,
|
||||||
|
)
|
||||||
|
STYLE_AI_BODY = ParagraphStyle(
|
||||||
|
"ai_body", fontSize=8.5, fontName="Helvetica",
|
||||||
|
textColor=NAVY2, leading=13, spaceAfter=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
story.append(Paragraph(
|
||||||
|
"Generado por DealAnalyzer — Ollama local | Solo orientativo, verificar con inspección física",
|
||||||
|
STYLE_AI_LABEL,
|
||||||
|
))
|
||||||
|
|
||||||
|
if not ai_text:
|
||||||
|
story.append(Paragraph(
|
||||||
|
"⚠️ Ollama no disponible — análisis IA omitido",
|
||||||
|
STYLE_BODY,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Detect recommendation keyword for badge
|
||||||
|
ai_upper = ai_text.upper()
|
||||||
|
if "EVITAR" in ai_upper:
|
||||||
|
rec_text, rec_color = "🔴 RECOMENDACIÓN: EVITAR", "red"
|
||||||
|
elif "INVESTIGAR" in ai_upper:
|
||||||
|
rec_text, rec_color = "🟡 RECOMENDACIÓN: INVESTIGAR MÁS", "yellow"
|
||||||
|
elif "COMPRAR" in ai_upper:
|
||||||
|
rec_text, rec_color = "🟢 RECOMENDACIÓN: COMPRAR", "green"
|
||||||
|
else:
|
||||||
|
rec_text, rec_color = None, None
|
||||||
|
|
||||||
|
# AI text box with light-blue border
|
||||||
|
ai_box = Table(
|
||||||
|
[[Paragraph(ai_text, STYLE_AI_BODY)]],
|
||||||
|
colWidths=[W],
|
||||||
|
)
|
||||||
|
ai_box.setStyle(TableStyle([
|
||||||
|
("BACKGROUND", (0, 0), (-1, -1), HexColor("#f0f4f8")),
|
||||||
|
("LINEABOVE", (0, 0), (-1, 0), 1.5, HexColor("#2980b9")),
|
||||||
|
("LEFTPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("RIGHTPADDING", (0, 0), (-1, -1), 10),
|
||||||
|
("TOPPADDING", (0, 0), (-1, -1), 8),
|
||||||
|
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
||||||
|
]))
|
||||||
|
story.append(ai_box)
|
||||||
|
story.append(Spacer(1, 6))
|
||||||
|
|
||||||
|
if rec_text:
|
||||||
|
story.append(_badge(rec_text, rec_color))
|
||||||
|
|
||||||
|
# ── Footer ────────────────────────────────────────────────────────────────
|
||||||
|
story.append(Spacer(1, 12))
|
||||||
|
story.append(HRFlowable(width=W, thickness=0.5, color=MGRAY))
|
||||||
|
story.append(Spacer(1, 4))
|
||||||
|
story.append(Paragraph(
|
||||||
|
"Datos de NHTSA.gov y EPA.gov — Información pública oficial del gobierno de EE.UU.",
|
||||||
|
STYLE_FOOT,
|
||||||
|
))
|
||||||
|
story.append(Paragraph(
|
||||||
|
"Este reporte no reemplaza un Carfax o inspección física profesional.",
|
||||||
|
STYLE_FOOT,
|
||||||
|
))
|
||||||
|
|
||||||
|
doc.build(story)
|
||||||
|
return filename
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
_CRITICAL_RECALL_KEYWORDS = (
|
||||||
|
"engine", "motor", "transmission", "fuel", "fire", "crash",
|
||||||
|
"brake", "steering", "airbag", "rollover",
|
||||||
|
)
|
||||||
|
|
||||||
|
_ENGINE_COMPLAINT_KEYWORDS = ("engine", "fuel system", "motor", "cooling")
|
||||||
|
_POWERTRAIN_COMPLAINT_KEYWORDS = ("power train", "powertrain", "transmission", "driveline")
|
||||||
|
|
||||||
|
|
||||||
|
def _component_text(item: dict) -> str:
|
||||||
|
return (
|
||||||
|
(item.get("Component") or item.get("component") or "")
|
||||||
|
+ " "
|
||||||
|
+ (item.get("components") or "")
|
||||||
|
).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def compute_extended_fields(
|
||||||
|
title: str,
|
||||||
|
recalls: list,
|
||||||
|
complaints: list,
|
||||||
|
odometer: str,
|
||||||
|
secondary_damage: str,
|
||||||
|
) -> dict:
|
||||||
|
critical_recalls = [
|
||||||
|
r for r in recalls
|
||||||
|
if any(kw in _component_text(r) for kw in _CRITICAL_RECALL_KEYWORDS)
|
||||||
|
]
|
||||||
|
|
||||||
|
engine_complaints = sum(
|
||||||
|
1 for c in complaints
|
||||||
|
if any(kw in _component_text(c) for kw in _ENGINE_COMPLAINT_KEYWORDS)
|
||||||
|
)
|
||||||
|
|
||||||
|
transmission_complaints = sum(
|
||||||
|
1 for c in complaints
|
||||||
|
if any(kw in _component_text(c) for kw in _POWERTRAIN_COMPLAINT_KEYWORDS)
|
||||||
|
)
|
||||||
|
|
||||||
|
alerts: list[str] = []
|
||||||
|
title_lower = (title or "").lower()
|
||||||
|
sec_lower = (secondary_damage or "").lower()
|
||||||
|
|
||||||
|
if ("salvage" in title_lower or "rebuilt" in title_lower) and critical_recalls:
|
||||||
|
alerts.append("Título salvage + recalls críticos de seguridad")
|
||||||
|
if engine_complaints > 20:
|
||||||
|
alerts.append(f"Alto número de quejas de motor ({engine_complaints})")
|
||||||
|
if transmission_complaints > 15:
|
||||||
|
alerts.append(f"Alto número de quejas de transmisión ({transmission_complaints})")
|
||||||
|
if len(recalls) >= 5 and ("salvage" in title_lower or "rebuilt" in title_lower):
|
||||||
|
alerts.append("Salvage con 5+ recalls simultáneos")
|
||||||
|
if sec_lower and sec_lower not in ("none", "no damage", "", "n/a") and critical_recalls:
|
||||||
|
alerts.append("Daño secundario + recalls críticos")
|
||||||
|
|
||||||
|
try:
|
||||||
|
odo = int(str(odometer).replace(",", "").replace(" mi", "").replace(" miles", "").strip() or "0")
|
||||||
|
except ValueError:
|
||||||
|
odo = 0
|
||||||
|
|
||||||
|
if odo > 200_000:
|
||||||
|
alerts.append(f"Odómetro muy alto ({odo:,} mi)")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"critical_recalls": critical_recalls,
|
||||||
|
"critical_recall_count": len(critical_recalls),
|
||||||
|
"engine_complaints": engine_complaints,
|
||||||
|
"transmission_complaints": transmission_complaints,
|
||||||
|
"alerts": alerts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_risk(
|
||||||
|
title: str,
|
||||||
|
recalls: list,
|
||||||
|
secondary_damage: str,
|
||||||
|
complaints: list,
|
||||||
|
odometer: str,
|
||||||
|
) -> dict:
|
||||||
|
score = 0
|
||||||
|
factors: list[dict] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
odo = int(str(odometer).replace(",", "").replace(" mi", "").replace(" miles", "").strip() or "0")
|
||||||
|
except ValueError:
|
||||||
|
odo = 0
|
||||||
|
|
||||||
|
title_lower = (title or "").lower()
|
||||||
|
if "salvage" in title_lower or "rebuilt" in title_lower:
|
||||||
|
score += 20
|
||||||
|
factors.append({"label": "Título Salvage/Rebuilt", "points": 20, "dir": "+"})
|
||||||
|
elif "certificate" in title_lower and "title" in title_lower:
|
||||||
|
score -= 15
|
||||||
|
factors.append({"label": "Título limpio (Certificate)", "points": 15, "dir": "-"})
|
||||||
|
|
||||||
|
recall_count = len(recalls)
|
||||||
|
recall_pts = min(recall_count * 15, 30)
|
||||||
|
if recall_pts > 0:
|
||||||
|
score += recall_pts
|
||||||
|
factors.append({"label": f"{recall_count} recall(s) activos", "points": recall_pts, "dir": "+"})
|
||||||
|
|
||||||
|
sec = (secondary_damage or "").lower().strip()
|
||||||
|
if sec and sec not in ("none", "no damage", "", "n/a"):
|
||||||
|
score += 10
|
||||||
|
factors.append({"label": f"Daño secundario: {secondary_damage}", "points": 10, "dir": "+"})
|
||||||
|
|
||||||
|
complaint_count = len(complaints)
|
||||||
|
if complaint_count > 50:
|
||||||
|
score += 10
|
||||||
|
factors.append({"label": f"Alta cantidad de quejas ({complaint_count})", "points": 10, "dir": "+"})
|
||||||
|
|
||||||
|
if 0 < odo < 60_000:
|
||||||
|
score -= 10
|
||||||
|
factors.append({"label": f"Bajo kilometraje ({odo:,} mi)", "points": 10, "dir": "-"})
|
||||||
|
|
||||||
|
score = max(0, min(100, score))
|
||||||
|
|
||||||
|
if score < 30:
|
||||||
|
level, color, emoji = "BAJO RIESGO", "green", "🟢"
|
||||||
|
elif score < 60:
|
||||||
|
level, color, emoji = "RIESGO MEDIO", "yellow", "🟡"
|
||||||
|
else:
|
||||||
|
level, color, emoji = "ALTO RIESGO", "red", "🔴"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score": score,
|
||||||
|
"level": level,
|
||||||
|
"color": color,
|
||||||
|
"emoji": emoji,
|
||||||
|
"factors": factors,
|
||||||
|
"recall_count": recall_count,
|
||||||
|
"complaint_count": complaint_count,
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
_TRANSLITERATION = {
|
||||||
|
'A': 1, 'B': 2, 'C': 3, 'D': 4, 'E': 5, 'F': 6, 'G': 7, 'H': 8,
|
||||||
|
'J': 1, 'K': 2, 'L': 3, 'M': 4, 'N': 5, 'P': 7, 'R': 9,
|
||||||
|
'S': 2, 'T': 3, 'U': 4, 'V': 5, 'W': 6, 'X': 7, 'Y': 8, 'Z': 9,
|
||||||
|
'0': 0, '1': 1, '2': 2, '3': 3, '4': 4,
|
||||||
|
'5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_vin(vin: str) -> tuple[bool, str]:
|
||||||
|
vin = vin.strip().upper()
|
||||||
|
|
||||||
|
if len(vin) != 17:
|
||||||
|
return False, f"Debe tener 17 caracteres (tiene {len(vin)})"
|
||||||
|
|
||||||
|
for ch in ('I', 'O', 'Q'):
|
||||||
|
if ch in vin:
|
||||||
|
return False, f"El VIN no puede contener la letra '{ch}'"
|
||||||
|
|
||||||
|
for ch in vin:
|
||||||
|
if ch not in _TRANSLITERATION:
|
||||||
|
return False, f"Carácter inválido: '{ch}'"
|
||||||
|
|
||||||
|
total = sum(_TRANSLITERATION[c] * _WEIGHTS[i] for i, c in enumerate(vin))
|
||||||
|
remainder = total % 11
|
||||||
|
expected = 'X' if remainder == 10 else str(remainder)
|
||||||
|
|
||||||
|
if vin[8] != expected:
|
||||||
|
# Non-US vehicles may have non-standard checksums; warn but allow
|
||||||
|
pass
|
||||||
|
|
||||||
|
return True, ""
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/* ── Reset & Variables ─────────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg2: #161b22;
|
||||||
|
--bg3: #1c2128;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--muted: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--yellow: #d29922;
|
||||||
|
--red: #f85149;
|
||||||
|
--navy: #1a3a5c;
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 4px 16px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Topbar ────────────────────────────────────────────────────────────────── */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--bg2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.topbar-logo {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: -.3px;
|
||||||
|
}
|
||||||
|
.topbar-version {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout ────────────────────────────────────────────────────────────────── */
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px 1fr;
|
||||||
|
gap: 0;
|
||||||
|
flex: 1;
|
||||||
|
height: calc(100vh - 49px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Left panel ────────────────────────────────────────────────────────────── */
|
||||||
|
.panel-form {
|
||||||
|
background: var(--bg2);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 20px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.panel-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form ──────────────────────────────────────────────────────────────────── */
|
||||||
|
form { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .4px;
|
||||||
|
}
|
||||||
|
.req { color: var(--red); }
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="url"] {
|
||||||
|
background: var(--bg3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input:focus { border-color: var(--accent); }
|
||||||
|
input::placeholder { color: var(--muted); opacity: .6; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 9px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity .15s, transform .1s;
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(.98); }
|
||||||
|
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { opacity: .85; }
|
||||||
|
|
||||||
|
.btn-gen {
|
||||||
|
background: var(--green);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.btn-open {
|
||||||
|
background: var(--bg3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
|
||||||
|
.hint { font-size: 11px; color: var(--muted); line-height: 1.6; }
|
||||||
|
.mono { font-family: 'Cascadia Code', 'Consolas', monospace; font-size: 11px;
|
||||||
|
color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Right panel ────────────────────────────────────────────────────────────── */
|
||||||
|
.panel-cards {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ────────────────────────────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-icon { font-size: 48px; opacity: .4; }
|
||||||
|
|
||||||
|
/* ── VIN Card ───────────────────────────────────────────────────────────────── */
|
||||||
|
.vin-card {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn .25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.card-vin {
|
||||||
|
font-family: 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.card-vehicle { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||||
|
.card-dismiss {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.card-dismiss:hover { background: var(--border); color: var(--text); }
|
||||||
|
|
||||||
|
.card-body { padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
/* ── Progress log ───────────────────────────────────────────────────────────── */
|
||||||
|
.progress-log { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
transition: color .2s;
|
||||||
|
}
|
||||||
|
.log-item.success { color: var(--green); }
|
||||||
|
.log-item.error { color: var(--red); }
|
||||||
|
.log-item.warning { color: var(--yellow); }
|
||||||
|
.log-item.progress { color: var(--text); }
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .6s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Risk badge (in card) ────────────────────────────────────────────────────── */
|
||||||
|
.risk-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 4px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.risk-badge.green { background: rgba(63,185,80,.15); color: var(--green); border: 1px solid rgba(63,185,80,.3); }
|
||||||
|
.risk-badge.yellow { background: rgba(210,153,34,.15); color: var(--yellow); border: 1px solid rgba(210,153,34,.3); }
|
||||||
|
.risk-badge.red { background: rgba(248,81,73,.15); color: var(--red); border: 1px solid rgba(248,81,73,.3); }
|
||||||
|
|
||||||
|
/* ── Card actions ────────────────────────────────────────────────────────────── */
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-link {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.pdf-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Scrollbar ───────────────────────────────────────────────────────────────── */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const form = document.getElementById("fetchForm");
|
||||||
|
const fetchBtn = document.getElementById("fetchBtn");
|
||||||
|
const cardsContainer = document.getElementById("cardsContainer");
|
||||||
|
|
||||||
|
// Remove empty state on first card
|
||||||
|
function clearEmpty() {
|
||||||
|
const empty = cardsContainer.querySelector(".empty-state");
|
||||||
|
if (empty) empty.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create a new VIN card ──────────────────────────────────────────────────────
|
||||||
|
function createCard(vin) {
|
||||||
|
clearEmpty();
|
||||||
|
|
||||||
|
const safeVin = vin.replace(/[^A-Z0-9]/g, "");
|
||||||
|
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "vin-card";
|
||||||
|
card.id = `card-${safeVin}`;
|
||||||
|
|
||||||
|
// Build DOM without innerHTML interpolation to prevent XSS
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "card-header";
|
||||||
|
|
||||||
|
const headerLeft = document.createElement("div");
|
||||||
|
|
||||||
|
const vinEl = document.createElement("div");
|
||||||
|
vinEl.className = "card-vin";
|
||||||
|
vinEl.textContent = safeVin;
|
||||||
|
|
||||||
|
const vehicleEl = document.createElement("div");
|
||||||
|
vehicleEl.className = "card-vehicle";
|
||||||
|
vehicleEl.id = `vehicle-${safeVin}`;
|
||||||
|
vehicleEl.textContent = "Consultando...";
|
||||||
|
|
||||||
|
headerLeft.appendChild(vinEl);
|
||||||
|
headerLeft.appendChild(vehicleEl);
|
||||||
|
|
||||||
|
const dismissBtn = document.createElement("button");
|
||||||
|
dismissBtn.className = "card-dismiss";
|
||||||
|
dismissBtn.title = "Cerrar";
|
||||||
|
dismissBtn.textContent = "✕";
|
||||||
|
dismissBtn.addEventListener("click", () => card.remove());
|
||||||
|
|
||||||
|
header.appendChild(headerLeft);
|
||||||
|
header.appendChild(dismissBtn);
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "card-body";
|
||||||
|
|
||||||
|
const logEl = document.createElement("div");
|
||||||
|
logEl.className = "progress-log";
|
||||||
|
logEl.id = `log-${safeVin}`;
|
||||||
|
|
||||||
|
const actionsEl = document.createElement("div");
|
||||||
|
actionsEl.id = `actions-${safeVin}`;
|
||||||
|
|
||||||
|
body.appendChild(logEl);
|
||||||
|
body.appendChild(actionsEl);
|
||||||
|
|
||||||
|
card.appendChild(header);
|
||||||
|
card.appendChild(body);
|
||||||
|
|
||||||
|
cardsContainer.prepend(card);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add a log line to a card ───────────────────────────────────────────────────
|
||||||
|
function addLog(vin, text, status) {
|
||||||
|
const log = document.getElementById(`log-${vin}`);
|
||||||
|
if (!log) return;
|
||||||
|
|
||||||
|
// Remove spinner from any previous progress item
|
||||||
|
if (status !== "progress") {
|
||||||
|
const prev = log.querySelector(".log-item.progress");
|
||||||
|
if (prev) prev.classList.replace("progress", "done");
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = `log-item ${status}`;
|
||||||
|
|
||||||
|
if (status === "progress") {
|
||||||
|
item.innerHTML = `<span class="spinner"></span><span>${text}</span>`;
|
||||||
|
} else {
|
||||||
|
item.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.appendChild(item);
|
||||||
|
item.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Show card actions after fetch completes ───────────────────────────────────
|
||||||
|
function showActions(vin, risk) {
|
||||||
|
const actionsDiv = document.getElementById(`actions-${vin}`);
|
||||||
|
if (!actionsDiv) return;
|
||||||
|
|
||||||
|
// Risk badge
|
||||||
|
const badge = document.createElement("div");
|
||||||
|
badge.className = `risk-badge ${risk.color}`;
|
||||||
|
badge.textContent = `${risk.emoji} ${risk.score}/100 — ${risk.level}`;
|
||||||
|
actionsDiv.appendChild(badge);
|
||||||
|
|
||||||
|
// Buttons row
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "card-actions";
|
||||||
|
|
||||||
|
const genBtn = document.createElement("button");
|
||||||
|
genBtn.className = "btn btn-gen";
|
||||||
|
genBtn.textContent = "📄 Generar PDF";
|
||||||
|
genBtn.addEventListener("click", () => generatePDF(vin, genBtn));
|
||||||
|
|
||||||
|
row.appendChild(genBtn);
|
||||||
|
actionsDiv.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generate PDF ──────────────────────────────────────────────────────────────
|
||||||
|
async function generatePDF(vin, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "⏳ Generando...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ vin }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "📄 Generar PDF";
|
||||||
|
addLog(vin, `❌ ${data.error}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.textContent = "✅ PDF listo";
|
||||||
|
|
||||||
|
const actionsDiv = document.getElementById(`actions-${vin}`);
|
||||||
|
|
||||||
|
// Download link
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = data.url;
|
||||||
|
link.download = data.filename;
|
||||||
|
link.className = "pdf-link";
|
||||||
|
link.textContent = `⬇️ ${data.filename}`;
|
||||||
|
actionsDiv.appendChild(link);
|
||||||
|
|
||||||
|
// Open locally button (Windows only via server)
|
||||||
|
const openBtn = document.createElement("button");
|
||||||
|
openBtn.className = "btn btn-open";
|
||||||
|
openBtn.textContent = "🗂️ Abrir localmente";
|
||||||
|
openBtn.addEventListener("click", () => openLocally(data.filename, openBtn));
|
||||||
|
actionsDiv.querySelector(".card-actions").appendChild(openBtn);
|
||||||
|
|
||||||
|
addLog(vin, `✅ PDF generado: ${data.filename}`, "success");
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "📄 Generar PDF";
|
||||||
|
addLog(vin, `❌ Error al generar PDF: ${e.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open file locally via server ──────────────────────────────────────────────
|
||||||
|
async function openLocally(filename, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/open", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ filename }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
alert(data.error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert("Error al abrir: " + e.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form submission → SSE ──────────────────────────────────────────────────────
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const vin = (fd.get("vin") || "").trim().toUpperCase();
|
||||||
|
if (!vin) return;
|
||||||
|
|
||||||
|
// Prevent duplicate active cards
|
||||||
|
const existing = document.getElementById(`card-${vin}`);
|
||||||
|
if (existing) {
|
||||||
|
existing.scrollIntoView({ behavior: "smooth" });
|
||||||
|
existing.style.outline = "2px solid var(--accent)";
|
||||||
|
setTimeout(() => (existing.style.outline = ""), 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchBtn.disabled = true;
|
||||||
|
fetchBtn.textContent = "⏳ Consultando...";
|
||||||
|
|
||||||
|
createCard(vin);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
vin,
|
||||||
|
odometer: fd.get("odometer") || "",
|
||||||
|
primary_damage: fd.get("primary_damage") || "",
|
||||||
|
secondary_damage: fd.get("secondary_damage") || "",
|
||||||
|
title: fd.get("title") || "",
|
||||||
|
bid: fd.get("bid") || "",
|
||||||
|
auction: fd.get("auction") || "",
|
||||||
|
photo_url: fd.get("photo_url") || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const es = new EventSource(`/api/fetch?${params.toString()}`);
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(event.data); } catch { return; }
|
||||||
|
|
||||||
|
const status = msg.status || "progress";
|
||||||
|
|
||||||
|
if (status === "done") {
|
||||||
|
const vehicleEl = document.getElementById(`vehicle-${vin}`);
|
||||||
|
if (vehicleEl && msg.vehicle) vehicleEl.textContent = msg.vehicle;
|
||||||
|
showActions(vin, msg.risk);
|
||||||
|
es.close();
|
||||||
|
fetchBtn.disabled = false;
|
||||||
|
fetchBtn.textContent = "⚡ FETCH DATA";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "error") {
|
||||||
|
addLog(vin, msg.step, "error");
|
||||||
|
es.close();
|
||||||
|
fetchBtn.disabled = false;
|
||||||
|
fetchBtn.textContent = "⚡ FETCH DATA";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "success" && msg.vehicle) {
|
||||||
|
const vehicleEl = document.getElementById(`vehicle-${vin}`);
|
||||||
|
if (vehicleEl) vehicleEl.textContent = msg.vehicle;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog(vin, msg.step, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
addLog(vin, "❌ Conexión interrumpida con el servidor", "error");
|
||||||
|
es.close();
|
||||||
|
fetchBtn.disabled = false;
|
||||||
|
fetchBtn.textContent = "⚡ FETCH DATA";
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AR-VinReport</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<span class="topbar-logo">🚗 AR-VinReport</span>
|
||||||
|
<span class="topbar-version">v1.0 — NHTSA · EPA · PDF</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<!-- ── Left panel: form ──────────────────────────────────────────── -->
|
||||||
|
<aside class="panel-form">
|
||||||
|
<h2 class="panel-title">Nuevo Reporte</h2>
|
||||||
|
|
||||||
|
<form id="fetchForm" autocomplete="off">
|
||||||
|
<div class="field">
|
||||||
|
<label for="vin">VIN <span class="req">*</span></label>
|
||||||
|
<input id="vin" name="vin" type="text" maxlength="17"
|
||||||
|
placeholder="17 caracteres" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="odometer">Odómetro (mi)</label>
|
||||||
|
<input id="odometer" name="odometer" type="text" placeholder="ej: 57,909" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="primary_damage">Daño Primario</label>
|
||||||
|
<input id="primary_damage" name="primary_damage" type="text"
|
||||||
|
placeholder="ej: Normal Wear" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="secondary_damage">Daño Secundario</label>
|
||||||
|
<input id="secondary_damage" name="secondary_damage" type="text"
|
||||||
|
placeholder="ej: Damage History" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="title">Título</label>
|
||||||
|
<input id="title" name="title" type="text"
|
||||||
|
placeholder="ej: NY - Certificate of Title" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="bid">Bid actual ($)</label>
|
||||||
|
<input id="bid" name="bid" type="text" placeholder="ej: 4,100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="auction">Subasta</label>
|
||||||
|
<input id="auction" name="auction" type="text" placeholder="ej: Copart" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="photo_url">URL de foto (opcional)</label>
|
||||||
|
<input id="photo_url" name="photo_url" type="url"
|
||||||
|
placeholder="https://..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" id="fetchBtn">
|
||||||
|
⚡ FETCH DATA
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="divider" />
|
||||||
|
<p class="hint">VIN de prueba:<br />
|
||||||
|
<code class="mono">1FTVW1EL1NWG14881</code>
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── Right panel: active cards ─────────────────────────────────── -->
|
||||||
|
<main class="panel-cards">
|
||||||
|
<h2 class="panel-title">Reportes Activos</h2>
|
||||||
|
<div id="cardsContainer" class="cards-container">
|
||||||
|
<div class="empty-state">
|
||||||
|
<span class="empty-icon">📋</span>
|
||||||
|
<p>Ingresa un VIN y haz click en <b>FETCH DATA</b> para comenzar.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user