first commit
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
57
app/api/cars.py
Normal file
57
app/api/cars.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.car import Car
|
||||
from app.schemas.car import CarCreate, CarRead, CarUpdate
|
||||
|
||||
router = APIRouter(prefix="/cars", tags=["cars"])
|
||||
|
||||
|
||||
@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_car(payload: CarCreate, session: AsyncSession = Depends(get_session)) -> Car:
|
||||
car = Car(**payload.model_dump())
|
||||
session.add(car)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
return car
|
||||
|
||||
|
||||
@router.get("", response_model=list[CarRead])
|
||||
async def list_cars(owner_id: int, session: AsyncSession = Depends(get_session)) -> list[Car]:
|
||||
result = await session.execute(
|
||||
select(Car).where(Car.owner_id == owner_id).order_by(Car.created_at.desc())
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
@router.get("/{car_id}", response_model=CarRead)
|
||||
async def get_car(car_id: int, session: AsyncSession = Depends(get_session)) -> Car:
|
||||
car = await session.get(Car, car_id)
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
return car
|
||||
|
||||
|
||||
@router.patch("/{car_id}", response_model=CarRead)
|
||||
async def update_car(
|
||||
car_id: int, payload: CarUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> Car:
|
||||
car = await session.get(Car, car_id)
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(car, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(car)
|
||||
return car
|
||||
|
||||
|
||||
@router.delete("/{car_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_car(car_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
car = await session.get(Car, car_id)
|
||||
if car is None:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
await session.delete(car)
|
||||
await session.commit()
|
||||
21
app/api/catalog.py
Normal file
21
app/api/catalog.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.car import CarMake
|
||||
from app.schemas.car import CarMakeRead
|
||||
|
||||
router = APIRouter(prefix="/catalog", tags=["catalog"])
|
||||
|
||||
|
||||
@router.get("/makes", response_model=list[CarMakeRead])
|
||||
async def list_makes(session: AsyncSession = Depends(get_session)) -> list[CarMake]:
|
||||
result = await session.execute(
|
||||
select(CarMake).options(selectinload(CarMake.models)).order_by(CarMake.name)
|
||||
)
|
||||
makes = list(result.scalars())
|
||||
for make in makes:
|
||||
make.models.sort(key=lambda model: model.name)
|
||||
return makes
|
||||
160
app/api/entries.py
Normal file
160
app/api/entries.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from io import BytesIO
|
||||
from datetime import date
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.car import Car
|
||||
from app.models.expense import FuelEntry, ServiceEntry
|
||||
from app.schemas.expense import (
|
||||
FuelEntryCreate,
|
||||
FuelEntryRead,
|
||||
OdometerPrediction,
|
||||
OwnershipStats,
|
||||
ServiceEntryCreate,
|
||||
ServiceEntryRead,
|
||||
)
|
||||
from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer
|
||||
|
||||
router = APIRouter(tags=["entries"])
|
||||
|
||||
|
||||
async def ensure_car(session: AsyncSession, car_id: int) -> None:
|
||||
if await session.get(Car, car_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
|
||||
|
||||
@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_fuel_entry(
|
||||
payload: FuelEntryCreate, session: AsyncSession = Depends(get_session)
|
||||
) -> FuelEntry:
|
||||
await ensure_car(session, payload.car_id)
|
||||
entry = FuelEntry(**payload.model_dump())
|
||||
session.add(entry)
|
||||
car = await session.get(Car, payload.car_id)
|
||||
if car and (car.current_odometer is None or payload.odometer > car.current_odometer):
|
||||
car.current_odometer = payload.odometer
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/cars/{car_id}/fuel", response_model=list[FuelEntryRead])
|
||||
async def list_fuel_entries(
|
||||
car_id: int,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[FuelEntry]:
|
||||
stmt = select(FuelEntry).where(FuelEntry.car_id == car_id)
|
||||
if date_from:
|
||||
stmt = stmt.where(FuelEntry.entry_date >= date_from)
|
||||
if date_to:
|
||||
stmt = stmt.where(FuelEntry.entry_date <= date_to)
|
||||
result = await session.execute(
|
||||
stmt.order_by(FuelEntry.entry_date.desc())
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
@router.post("/service", response_model=ServiceEntryRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_service_entry(
|
||||
payload: ServiceEntryCreate, session: AsyncSession = Depends(get_session)
|
||||
) -> ServiceEntry:
|
||||
await ensure_car(session, payload.car_id)
|
||||
entry = ServiceEntry(**payload.model_dump())
|
||||
session.add(entry)
|
||||
car = await session.get(Car, payload.car_id)
|
||||
if car and payload.odometer and (
|
||||
car.current_odometer is None or payload.odometer > car.current_odometer
|
||||
):
|
||||
car.current_odometer = payload.odometer
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/cars/{car_id}/service", response_model=list[ServiceEntryRead])
|
||||
async def list_service_entries(
|
||||
car_id: int,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ServiceEntry]:
|
||||
stmt = select(ServiceEntry).where(ServiceEntry.car_id == car_id)
|
||||
if date_from:
|
||||
stmt = stmt.where(ServiceEntry.entry_date >= date_from)
|
||||
if date_to:
|
||||
stmt = stmt.where(ServiceEntry.entry_date <= date_to)
|
||||
result = await session.execute(
|
||||
stmt.order_by(ServiceEntry.entry_date.desc())
|
||||
)
|
||||
return list(result.scalars())
|
||||
|
||||
|
||||
@router.get("/cars/{car_id}/stats", response_model=OwnershipStats)
|
||||
async def car_stats(
|
||||
car_id: int,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OwnershipStats:
|
||||
await ensure_car(session, car_id)
|
||||
today = date.today()
|
||||
period_from = date_from or today.replace(day=1)
|
||||
period_to = date_to or today
|
||||
return await get_ownership_stats(session, car_id, period_from, period_to)
|
||||
|
||||
|
||||
@router.get("/cars/{car_id}/analytics", response_model=OdometerPrediction)
|
||||
async def car_analytics(car_id: int, session: AsyncSession = Depends(get_session)) -> OdometerPrediction:
|
||||
await ensure_car(session, car_id)
|
||||
return await predict_odometer(session, car_id)
|
||||
|
||||
|
||||
@router.get("/cars/{car_id}/charts/expenses.png")
|
||||
async def expenses_chart(car_id: int, session: AsyncSession = Depends(get_session)) -> Response:
|
||||
await ensure_car(session, car_id)
|
||||
fuel_df = await dataframe_from_query(
|
||||
session,
|
||||
select(FuelEntry.entry_date.label("date"), FuelEntry.total_cost.label("cost")).where(
|
||||
FuelEntry.car_id == car_id
|
||||
),
|
||||
)
|
||||
service_df = await dataframe_from_query(
|
||||
session,
|
||||
select(ServiceEntry.entry_date.label("date"), ServiceEntry.total_cost.label("cost")).where(
|
||||
ServiceEntry.car_id == car_id
|
||||
),
|
||||
)
|
||||
if fuel_df.empty and service_df.empty:
|
||||
raise HTTPException(status_code=404, detail="No data for chart")
|
||||
|
||||
frames = []
|
||||
if not fuel_df.empty:
|
||||
fuel_df["type"] = "fuel"
|
||||
frames.append(fuel_df)
|
||||
if not service_df.empty:
|
||||
service_df["type"] = "service"
|
||||
frames.append(service_df)
|
||||
|
||||
import pandas as pd
|
||||
|
||||
df = pd.concat(frames)
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
pivot = df.pivot_table(index="date", columns="type", values="cost", aggfunc="sum").sort_index()
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5))
|
||||
pivot.plot(kind="bar", stacked=True, ax=ax)
|
||||
ax.set_title("Car expenses")
|
||||
ax.set_xlabel("Date")
|
||||
ax.set_ylabel("Cost")
|
||||
fig.tight_layout()
|
||||
|
||||
buffer = BytesIO()
|
||||
fig.savefig(buffer, format="png")
|
||||
plt.close(fig)
|
||||
return Response(buffer.getvalue(), media_type="image/png")
|
||||
41
app/api/ocr.py
Normal file
41
app/api/ocr.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/ocr", tags=["ocr"])
|
||||
|
||||
|
||||
class ReceiptSuggestion(BaseModel):
|
||||
total_cost: Decimal | None = None
|
||||
liters: Decimal | None = None
|
||||
price_per_liter: Decimal | None = None
|
||||
station: str | None = None
|
||||
confidence: float
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/fuel-receipt", response_model=ReceiptSuggestion)
|
||||
async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion:
|
||||
content = await file.read()
|
||||
text = content.decode("utf-8", errors="ignore")
|
||||
numbers = [Decimal(item.replace(",", ".")) for item in re.findall(r"\d+[,.]\d+|\d+", text)]
|
||||
total = max(numbers) if numbers else None
|
||||
liters = next((item for item in numbers if Decimal("5") <= item <= Decimal("120")), None)
|
||||
price = None
|
||||
if total and liters and liters:
|
||||
price = (total / liters).quantize(Decimal("0.01"))
|
||||
|
||||
return ReceiptSuggestion(
|
||||
total_cost=total,
|
||||
liters=liters,
|
||||
price_per_liter=price,
|
||||
station=None,
|
||||
confidence=0.35 if numbers else 0,
|
||||
message=(
|
||||
"OCR-модуль готов к подключению движка распознавания. Сейчас извлекаю числа из текстового слоя/имени файла."
|
||||
if numbers
|
||||
else "Не удалось распознать чек. Можно заполнить поля вручную, а OCR-движок подключить отдельным сервисом."
|
||||
),
|
||||
)
|
||||
46
app/api/users.py
Normal file
46
app/api/users.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserPreferencesUpdate, UserRead, UserUpsert
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.post("", response_model=UserRead)
|
||||
async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User:
|
||||
result = await session.execute(select(User).where(User.telegram_id == payload.telegram_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(**payload.model_dump(exclude_none=True))
|
||||
session.add(user)
|
||||
else:
|
||||
for field, value in payload.model_dump(exclude_none=True).items():
|
||||
setattr(user, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/telegram/{telegram_id}", response_model=UserRead)
|
||||
async def get_user_by_telegram_id(
|
||||
telegram_id: int, session: AsyncSession = Depends(get_session)
|
||||
) -> User:
|
||||
result = await session.execute(select(User).where(User.telegram_id == telegram_id))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
@router.patch("/{user_id}/preferences", response_model=UserRead)
|
||||
async def update_preferences(
|
||||
user_id: int, payload: UserPreferencesUpdate, session: AsyncSession = Depends(get_session)
|
||||
) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
for field, value in payload.model_dump(exclude_none=True).items():
|
||||
setattr(user, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
22
app/core/config.py
Normal file
22
app/core/config.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+asyncpg://drivers:drivers@localhost:5432/drivers"
|
||||
bot_token: str = ""
|
||||
api_base_url: str = "http://localhost:8000"
|
||||
webapp_url: str = "http://localhost:8000"
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
1
app/db/__init__.py
Normal file
1
app/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
5
app/db/base.py
Normal file
5
app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
214
app/db/seed.py
Normal file
214
app/db/seed.py
Normal file
@@ -0,0 +1,214 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.car import Car, CarMake, CarModel
|
||||
from app.models.expense import FuelEntry, ServiceEntry, ServiceType
|
||||
from app.models.user import User
|
||||
from app.services.catalog_data import CAR_CATALOG
|
||||
|
||||
|
||||
MOCK_PLATE_PREFIX = "MOCK"
|
||||
|
||||
MOCK_CARS = [
|
||||
("KIA Sportage", "KIA", "Sportage", 2021, "gasoline", 36200, Decimal("2450000")),
|
||||
("Toyota Camry", "Toyota", "Camry", 2020, "gasoline", 58400, Decimal("2850000")),
|
||||
("Hyundai Tucson", "Hyundai", "Tucson", 2022, "gasoline", 27100, Decimal("2750000")),
|
||||
("Volkswagen Tiguan", "Volkswagen", "Tiguan", 2019, "gasoline", 73400, Decimal("2300000")),
|
||||
("BMW X3", "BMW", "X3", 2021, "diesel", 48900, Decimal("4350000")),
|
||||
("Mercedes GLC", "Mercedes", "GLC", 2020, "gasoline", 52200, Decimal("4500000")),
|
||||
("Nissan X-Trail", "Nissan", "X-Trail", 2018, "gasoline", 91400, Decimal("1850000")),
|
||||
("Skoda Octavia", "Skoda", "Octavia", 2021, "gasoline", 46800, Decimal("2050000")),
|
||||
("Tesla Model 3", "Tesla", "Model 3", 2022, "electric", 33800, Decimal("3900000")),
|
||||
("Haval Jolion", "Haval", "Jolion", 2023, "gasoline", 19600, Decimal("2150000")),
|
||||
]
|
||||
|
||||
|
||||
def month_shift(base: date, months_back: int) -> date:
|
||||
month_index = base.year * 12 + base.month - 1 - months_back
|
||||
year = month_index // 12
|
||||
month = month_index % 12 + 1
|
||||
return date(year, month, min(base.day, 24))
|
||||
|
||||
|
||||
async def seed_catalog(session: AsyncSession) -> None:
|
||||
result = await session.execute(select(CarMake).options(selectinload(CarMake.models)))
|
||||
existing = {make.name: make for make in result.scalars()}
|
||||
|
||||
for make_name, model_names in CAR_CATALOG.items():
|
||||
make = existing.get(make_name)
|
||||
if make is None:
|
||||
make = CarMake(name=make_name)
|
||||
session.add(make)
|
||||
await session.flush()
|
||||
existing_models = set()
|
||||
else:
|
||||
existing_models = {model.name for model in make.models}
|
||||
for model_name in model_names:
|
||||
if model_name not in existing_models:
|
||||
session.add(CarModel(make_id=make.id, name=model_name))
|
||||
|
||||
|
||||
async def pick_owner(session: AsyncSession) -> User:
|
||||
result = await session.execute(select(User).where(User.telegram_id == 1))
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
user = User(telegram_id=1, username="demo", first_name="Demo")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def clear_previous_mock(session: AsyncSession) -> None:
|
||||
result = await session.execute(
|
||||
select(Car.id).where(Car.plate_number.like(f"{MOCK_PLATE_PREFIX}-%"))
|
||||
)
|
||||
car_ids = list(result.scalars())
|
||||
if not car_ids:
|
||||
return
|
||||
await session.execute(delete(ServiceEntry).where(ServiceEntry.car_id.in_(car_ids)))
|
||||
await session.execute(delete(FuelEntry).where(FuelEntry.car_id.in_(car_ids)))
|
||||
await session.execute(delete(Car).where(Car.id.in_(car_ids)))
|
||||
|
||||
|
||||
async def seed_mock_usage(session: AsyncSession, owner: User) -> None:
|
||||
await clear_previous_mock(session)
|
||||
today = date.today()
|
||||
|
||||
for index, (name, make, model, year, fuel_type, start_odo, price) in enumerate(MOCK_CARS, start=1):
|
||||
car = Car(
|
||||
owner_id=owner.id,
|
||||
name=name,
|
||||
make=make,
|
||||
model=model,
|
||||
year=year,
|
||||
plate_number=f"{MOCK_PLATE_PREFIX}-{index:02d}",
|
||||
fuel_type=fuel_type,
|
||||
purchase_date=date(year, min(index, 12), 10),
|
||||
purchase_price=price,
|
||||
current_odometer=start_odo,
|
||||
)
|
||||
session.add(car)
|
||||
await session.flush()
|
||||
|
||||
odometer = start_odo
|
||||
monthly_km = 820 + index * 95
|
||||
consumption = Decimal("7.2") + Decimal(index % 5) * Decimal("0.45")
|
||||
if fuel_type == "electric":
|
||||
consumption = Decimal("0")
|
||||
|
||||
for months_back in range(11, -1, -1):
|
||||
base_day = month_shift(today.replace(day=15), months_back)
|
||||
odometer += monthly_km
|
||||
|
||||
if fuel_type == "electric":
|
||||
energy_cost = Decimal(110 + index * 8 + months_back % 3 * 15)
|
||||
session.add(
|
||||
ServiceEntry(
|
||||
car_id=car.id,
|
||||
entry_date=base_day,
|
||||
odometer=odometer,
|
||||
service_type=ServiceType.other,
|
||||
title="Зарядка и парковка",
|
||||
category="charging",
|
||||
vendor="EV network",
|
||||
total_cost=energy_cost,
|
||||
)
|
||||
)
|
||||
else:
|
||||
liters_per_month = Decimal(monthly_km) * consumption / Decimal(100)
|
||||
price_per_liter = Decimal("58.50") + Decimal(index % 4) * Decimal("2.10")
|
||||
for fill in range(2):
|
||||
fill_date = date(base_day.year, base_day.month, 8 if fill == 0 else 22)
|
||||
liters = (liters_per_month / 2).quantize(Decimal("0.001"))
|
||||
session.add(
|
||||
FuelEntry(
|
||||
car_id=car.id,
|
||||
entry_date=fill_date,
|
||||
odometer=odometer - (monthly_km // 2 if fill == 0 else 0),
|
||||
liters=liters,
|
||||
price_per_liter=price_per_liter,
|
||||
total_cost=(liters * price_per_liter).quantize(Decimal("0.01")),
|
||||
station=["Shell", "Lukoil", "Gazprom", "Rosneft", "Neste"][index % 5],
|
||||
is_full_tank=True,
|
||||
)
|
||||
)
|
||||
|
||||
if months_back in {11, 5}:
|
||||
session.add(
|
||||
ServiceEntry(
|
||||
car_id=car.id,
|
||||
entry_date=date(base_day.year, base_day.month, 12),
|
||||
odometer=odometer,
|
||||
service_type=ServiceType.maintenance,
|
||||
title="Плановое ТО",
|
||||
category="regular",
|
||||
vendor="Service center",
|
||||
total_cost=Decimal(12500 + index * 950),
|
||||
next_due_odometer=odometer + 10000,
|
||||
)
|
||||
)
|
||||
|
||||
if months_back in {8, 2}:
|
||||
session.add(
|
||||
ServiceEntry(
|
||||
car_id=car.id,
|
||||
entry_date=date(base_day.year, base_day.month, 18),
|
||||
odometer=odometer,
|
||||
service_type=ServiceType.tire,
|
||||
title="Сезонная замена шин",
|
||||
category="tires",
|
||||
vendor="Tire shop",
|
||||
total_cost=Decimal(4200 + index * 180),
|
||||
)
|
||||
)
|
||||
|
||||
if months_back == 6:
|
||||
session.add(
|
||||
ServiceEntry(
|
||||
car_id=car.id,
|
||||
entry_date=date(base_day.year, base_day.month, 20),
|
||||
odometer=odometer,
|
||||
service_type=ServiceType.insurance,
|
||||
title="ОСАГО / страховка",
|
||||
category="insurance",
|
||||
vendor="Insurance",
|
||||
total_cost=Decimal(18200 + index * 1150),
|
||||
)
|
||||
)
|
||||
|
||||
if months_back == index % 10:
|
||||
session.add(
|
||||
ServiceEntry(
|
||||
car_id=car.id,
|
||||
entry_date=date(base_day.year, base_day.month, 25),
|
||||
odometer=odometer,
|
||||
service_type=ServiceType.repair,
|
||||
title=["Тормозные колодки", "Диагностика подвески", "Замена АКБ", "Ремонт кондиционера"][index % 4],
|
||||
category="repair",
|
||||
vendor="Garage",
|
||||
total_cost=Decimal(7200 + index * 1350),
|
||||
)
|
||||
)
|
||||
|
||||
car.current_odometer = odometer
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with async_session_factory() as session:
|
||||
await seed_catalog(session)
|
||||
owner = await pick_owner(session)
|
||||
await seed_mock_usage(session, owner)
|
||||
await session.commit()
|
||||
print(f"Seeded catalog and mock usage for owner_id={owner.id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
13
app/db/session.py
Normal file
13
app/db/session.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
29
app/main.py
Normal file
29
app/main.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api import cars, catalog, entries, ocr, users
|
||||
|
||||
app = FastAPI(title="Drivers Bot API", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(users.router, prefix="/api")
|
||||
app.include_router(catalog.router, prefix="/api")
|
||||
app.include_router(cars.router, prefix="/api")
|
||||
app.include_router(entries.router, prefix="/api")
|
||||
app.include_router(ocr.router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory="web", html=True), name="web")
|
||||
1
app/models/__init__.py
Normal file
1
app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
55
app/models/car.py
Normal file
55
app/models/car.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Car(Base):
|
||||
__tablename__ = "cars"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(160))
|
||||
make: Mapped[str | None] = mapped_column(String(80))
|
||||
model: Mapped[str | None] = mapped_column(String(80))
|
||||
year: Mapped[int | None]
|
||||
plate_number: Mapped[str | None] = mapped_column(String(32))
|
||||
vin: Mapped[str | None] = mapped_column(String(32))
|
||||
fuel_type: Mapped[str | None] = mapped_column(String(32))
|
||||
purchase_date: Mapped[date | None] = mapped_column(Date)
|
||||
purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
current_odometer: Mapped[int | None]
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
owner = relationship("User", back_populates="cars")
|
||||
fuel_entries = relationship("FuelEntry", back_populates="car", cascade="all, delete-orphan")
|
||||
service_entries = relationship("ServiceEntry", back_populates="car", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class CarMake(Base):
|
||||
__tablename__ = "car_makes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(80), unique=True, index=True)
|
||||
country: Mapped[str | None] = mapped_column(String(80))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
models = relationship("CarModel", back_populates="make", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class CarModel(Base):
|
||||
__tablename__ = "car_models"
|
||||
__table_args__ = (UniqueConstraint("make_id", "name", name="uq_car_models_make_name"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
make_id: Mapped[int] = mapped_column(ForeignKey("car_makes.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
make = relationship("CarMake", back_populates="models")
|
||||
58
app/models/expense.py
Normal file
58
app/models/expense.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import enum
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class ServiceType(str, enum.Enum):
|
||||
maintenance = "maintenance"
|
||||
repair = "repair"
|
||||
fluid = "fluid"
|
||||
tire = "tire"
|
||||
inspection = "inspection"
|
||||
insurance = "insurance"
|
||||
tax = "tax"
|
||||
other = "other"
|
||||
|
||||
|
||||
class FuelEntry(Base):
|
||||
__tablename__ = "fuel_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
entry_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
odometer: Mapped[int]
|
||||
liters: Mapped[Decimal] = mapped_column(Numeric(8, 3))
|
||||
price_per_liter: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
||||
total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2))
|
||||
station: Mapped[str | None] = mapped_column(String(160))
|
||||
fuel_brand: Mapped[str | None] = mapped_column(String(80))
|
||||
is_full_tank: Mapped[bool] = mapped_column(default=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
car = relationship("Car", back_populates="fuel_entries")
|
||||
|
||||
|
||||
class ServiceEntry(Base):
|
||||
__tablename__ = "service_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True)
|
||||
entry_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
odometer: Mapped[int | None]
|
||||
service_type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
|
||||
title: Mapped[str] = mapped_column(String(180))
|
||||
category: Mapped[str | None] = mapped_column(String(80))
|
||||
vendor: Mapped[str | None] = mapped_column(String(160))
|
||||
total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2))
|
||||
next_due_date: Mapped[date | None] = mapped_column(Date)
|
||||
next_due_odometer: Mapped[int | None]
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
car = relationship("Car", back_populates="service_entries")
|
||||
24
app/models/user.py
Normal file
24
app/models/user.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
|
||||
username: Mapped[str | None] = mapped_column(String(128))
|
||||
first_name: Mapped[str | None] = mapped_column(String(128))
|
||||
last_name: Mapped[str | None] = mapped_column(String(128))
|
||||
locale: Mapped[str] = mapped_column(String(8), default="ru", server_default="ru")
|
||||
currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
cars = relationship("Car", back_populates="owner", cascade="all, delete-orphan")
|
||||
1
app/schemas/__init__.py
Normal file
1
app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
58
app/schemas/car.py
Normal file
58
app/schemas/car.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class CarBase(BaseModel):
|
||||
name: str
|
||||
make: str | None = None
|
||||
model: str | None = None
|
||||
year: int | None = None
|
||||
plate_number: str | None = None
|
||||
vin: str | None = None
|
||||
fuel_type: str | None = None
|
||||
purchase_date: date | None = None
|
||||
purchase_price: Decimal | None = None
|
||||
current_odometer: int | None = None
|
||||
|
||||
|
||||
class CarCreate(CarBase):
|
||||
owner_id: int
|
||||
|
||||
|
||||
class CarUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
make: str | None = None
|
||||
model: str | None = None
|
||||
year: int | None = None
|
||||
plate_number: str | None = None
|
||||
vin: str | None = None
|
||||
fuel_type: str | None = None
|
||||
purchase_date: date | None = None
|
||||
purchase_price: Decimal | None = None
|
||||
current_odometer: int | None = None
|
||||
|
||||
|
||||
class CarRead(CarBase):
|
||||
id: int
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CarModelRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CarMakeRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
country: str | None = None
|
||||
models: list[CarModelRead] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
89
app/schemas/expense.py
Normal file
89
app/schemas/expense.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from app.models.expense import ServiceType
|
||||
|
||||
|
||||
class FuelEntryBase(BaseModel):
|
||||
entry_date: date
|
||||
odometer: int
|
||||
liters: Decimal
|
||||
price_per_liter: Decimal
|
||||
total_cost: Decimal | None = None
|
||||
station: str | None = None
|
||||
fuel_brand: str | None = None
|
||||
is_full_tank: bool = True
|
||||
notes: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_total_cost(self) -> "FuelEntryBase":
|
||||
if self.total_cost is None:
|
||||
self.total_cost = self.liters * self.price_per_liter
|
||||
return self
|
||||
|
||||
|
||||
class FuelEntryCreate(FuelEntryBase):
|
||||
car_id: int
|
||||
|
||||
|
||||
class FuelEntryRead(FuelEntryBase):
|
||||
id: int
|
||||
car_id: int
|
||||
total_cost: Decimal
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ServiceEntryBase(BaseModel):
|
||||
entry_date: date
|
||||
odometer: int | None = None
|
||||
service_type: ServiceType
|
||||
title: str
|
||||
category: str | None = None
|
||||
vendor: str | None = None
|
||||
total_cost: Decimal
|
||||
next_due_date: date | None = None
|
||||
next_due_odometer: int | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ServiceEntryCreate(ServiceEntryBase):
|
||||
car_id: int
|
||||
|
||||
|
||||
class ServiceEntryRead(ServiceEntryBase):
|
||||
id: int
|
||||
car_id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class OwnershipStats(BaseModel):
|
||||
car_id: int
|
||||
date_from: date
|
||||
date_to: date
|
||||
fuel_cost: Decimal
|
||||
service_cost: Decimal
|
||||
total_cost: Decimal
|
||||
liters: Decimal
|
||||
distance_km: int
|
||||
avg_consumption_l_per_100km: float | None
|
||||
cost_per_km: float | None
|
||||
fuel_entries_count: int
|
||||
service_entries_count: int
|
||||
|
||||
|
||||
class OdometerPrediction(BaseModel):
|
||||
car_id: int
|
||||
samples: int
|
||||
current_odometer: int | None
|
||||
predicted_today: int | None
|
||||
predicted_30_days: int | None
|
||||
avg_km_per_day: float | None
|
||||
avg_km_per_month: float | None
|
||||
confidence: float
|
||||
insight: str
|
||||
26
app/schemas/user.py
Normal file
26
app/schemas/user.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class UserUpsert(BaseModel):
|
||||
telegram_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
locale: str | None = None
|
||||
currency: str | None = None
|
||||
|
||||
|
||||
class UserPreferencesUpdate(BaseModel):
|
||||
locale: str | None = None
|
||||
currency: str | None = None
|
||||
|
||||
|
||||
class UserRead(UserUpsert):
|
||||
id: int
|
||||
locale: str
|
||||
currency: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
134
app/services/calculations.py
Normal file
134
app/services/calculations.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pandas as pd
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.expense import FuelEntry, ServiceEntry
|
||||
from app.schemas.expense import OdometerPrediction, OwnershipStats
|
||||
|
||||
|
||||
async def get_ownership_stats(
|
||||
session: AsyncSession, car_id: int, date_from: date, date_to: date
|
||||
) -> OwnershipStats:
|
||||
fuel_totals = await session.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(FuelEntry.total_cost), 0),
|
||||
func.coalesce(func.sum(FuelEntry.liters), 0),
|
||||
func.count(FuelEntry.id),
|
||||
func.min(FuelEntry.odometer),
|
||||
func.max(FuelEntry.odometer),
|
||||
).where(
|
||||
FuelEntry.car_id == car_id,
|
||||
FuelEntry.entry_date >= date_from,
|
||||
FuelEntry.entry_date <= date_to,
|
||||
)
|
||||
)
|
||||
fuel_cost, liters, fuel_count, min_odo, max_odo = fuel_totals.one()
|
||||
|
||||
service_totals = await session.execute(
|
||||
select(func.coalesce(func.sum(ServiceEntry.total_cost), 0), func.count(ServiceEntry.id)).where(
|
||||
ServiceEntry.car_id == car_id,
|
||||
ServiceEntry.entry_date >= date_from,
|
||||
ServiceEntry.entry_date <= date_to,
|
||||
)
|
||||
)
|
||||
service_cost, service_count = service_totals.one()
|
||||
|
||||
distance_km = int(max_odo - min_odo) if min_odo is not None and max_odo is not None else 0
|
||||
total_cost = Decimal(fuel_cost) + Decimal(service_cost)
|
||||
avg_consumption = float(Decimal(liters) * Decimal(100) / distance_km) if distance_km else None
|
||||
cost_per_km = float(total_cost / distance_km) if distance_km else None
|
||||
|
||||
return OwnershipStats(
|
||||
car_id=car_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
fuel_cost=fuel_cost,
|
||||
service_cost=service_cost,
|
||||
total_cost=total_cost,
|
||||
liters=liters,
|
||||
distance_km=distance_km,
|
||||
avg_consumption_l_per_100km=avg_consumption,
|
||||
cost_per_km=cost_per_km,
|
||||
fuel_entries_count=fuel_count,
|
||||
service_entries_count=service_count,
|
||||
)
|
||||
|
||||
|
||||
async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFrame:
|
||||
result = await session.execute(stmt)
|
||||
rows = result.mappings().all()
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPrediction:
|
||||
fuel = await dataframe_from_query(
|
||||
session,
|
||||
select(FuelEntry.entry_date.label("date"), FuelEntry.odometer.label("odometer")).where(
|
||||
FuelEntry.car_id == car_id
|
||||
),
|
||||
)
|
||||
service = await dataframe_from_query(
|
||||
session,
|
||||
select(ServiceEntry.entry_date.label("date"), ServiceEntry.odometer.label("odometer")).where(
|
||||
ServiceEntry.car_id == car_id, ServiceEntry.odometer.is_not(None)
|
||||
),
|
||||
)
|
||||
if fuel.empty and service.empty:
|
||||
return OdometerPrediction(
|
||||
car_id=car_id,
|
||||
samples=0,
|
||||
current_odometer=None,
|
||||
predicted_today=None,
|
||||
predicted_30_days=None,
|
||||
avg_km_per_day=None,
|
||||
avg_km_per_month=None,
|
||||
confidence=0,
|
||||
insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.",
|
||||
)
|
||||
|
||||
df = pd.concat([fuel, service]).dropna().drop_duplicates().sort_values("date")
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
df = df.sort_values(["date", "odometer"]).drop_duplicates(subset=["date"], keep="last")
|
||||
if len(df) < 2:
|
||||
current = int(df.iloc[-1]["odometer"])
|
||||
return OdometerPrediction(
|
||||
car_id=car_id,
|
||||
samples=len(df),
|
||||
current_odometer=current,
|
||||
predicted_today=current,
|
||||
predicted_30_days=None,
|
||||
avg_km_per_day=None,
|
||||
avg_km_per_month=None,
|
||||
confidence=0.2,
|
||||
insight="Есть только одна точка пробега. Для прогноза нужны минимум две записи.",
|
||||
)
|
||||
|
||||
first = df.iloc[0]
|
||||
last = df.iloc[-1]
|
||||
days = max((last["date"] - first["date"]).days, 1)
|
||||
distance = max(int(last["odometer"] - first["odometer"]), 0)
|
||||
km_per_day = distance / days
|
||||
today = pd.Timestamp.utcnow().tz_localize(None).normalize()
|
||||
days_since_last = max((today - last["date"]).days, 0)
|
||||
predicted_today = int(last["odometer"] + km_per_day * days_since_last)
|
||||
predicted_30 = int(predicted_today + km_per_day * 30)
|
||||
confidence = min(0.95, 0.35 + len(df) * 0.035 + min(days, 365) / 730)
|
||||
insight = (
|
||||
"Пробег стабилен, прогноз надежный."
|
||||
if confidence >= 0.75
|
||||
else "Прогноз предварительный: точность вырастет после нескольких новых записей."
|
||||
)
|
||||
return OdometerPrediction(
|
||||
car_id=car_id,
|
||||
samples=len(df),
|
||||
current_odometer=int(last["odometer"]),
|
||||
predicted_today=predicted_today,
|
||||
predicted_30_days=predicted_30,
|
||||
avg_km_per_day=round(km_per_day, 1),
|
||||
avg_km_per_month=round(km_per_day * 30.4, 1),
|
||||
confidence=round(confidence, 2),
|
||||
insight=insight,
|
||||
)
|
||||
57
app/services/catalog_data.py
Normal file
57
app/services/catalog_data.py
Normal file
@@ -0,0 +1,57 @@
|
||||
CAR_CATALOG: dict[str, list[str]] = {
|
||||
"Acura": ["ILX", "Integra", "MDX", "RDX", "TLX", "TSX"],
|
||||
"Alfa Romeo": ["Giulia", "Giulietta", "Stelvio", "Tonale"],
|
||||
"Audi": ["A1", "A3", "A4", "A5", "A6", "A7", "A8", "Q2", "Q3", "Q5", "Q7", "Q8", "e-tron", "TT"],
|
||||
"BMW": ["1 Series", "2 Series", "3 Series", "4 Series", "5 Series", "7 Series", "X1", "X2", "X3", "X4", "X5", "X6", "X7", "i3", "i4", "iX"],
|
||||
"BYD": ["Atto 3", "Dolphin", "Han", "Seal", "Song Plus", "Tang"],
|
||||
"Cadillac": ["ATS", "CT4", "CT5", "Escalade", "SRX", "XT4", "XT5", "XT6"],
|
||||
"Changan": ["Alsvin", "CS35 Plus", "CS55 Plus", "CS75 Plus", "UNI-K", "UNI-T", "UNI-V"],
|
||||
"Chery": ["Arrizo 5", "Arrizo 8", "Tiggo 4", "Tiggo 7", "Tiggo 8", "Tiggo 9"],
|
||||
"Chevrolet": ["Aveo", "Camaro", "Captiva", "Cobalt", "Cruze", "Equinox", "Lacetti", "Malibu", "Niva", "Spark", "Tahoe", "Trailblazer"],
|
||||
"Citroen": ["Berlingo", "C3", "C4", "C5 Aircross", "C-Elysee", "Jumpy"],
|
||||
"Daewoo": ["Gentra", "Lanos", "Matiz", "Nexia"],
|
||||
"Daihatsu": ["Boon", "Copen", "Mira", "Rocky", "Terios"],
|
||||
"Dodge": ["Caliber", "Challenger", "Charger", "Durango", "Journey", "Ram"],
|
||||
"Exeed": ["LX", "RX", "TXL", "VX"],
|
||||
"FAW": ["Bestune B70", "Bestune T77", "Bestune T99", "Oley"],
|
||||
"Fiat": ["500", "Albea", "Doblo", "Ducato", "Panda", "Punto", "Tipo"],
|
||||
"Ford": ["Bronco", "EcoSport", "Edge", "Escape", "Explorer", "F-150", "Fiesta", "Focus", "Fusion", "Kuga", "Mondeo", "Mustang", "Ranger", "Transit"],
|
||||
"Geely": ["Atlas", "Coolray", "Emgrand", "Monjaro", "Okavango", "Preface", "Tugella"],
|
||||
"Genesis": ["G70", "G80", "G90", "GV60", "GV70", "GV80"],
|
||||
"GreatWall": ["Coolbear", "Hover", "Poer", "Safe", "Wingle"],
|
||||
"Haval": ["Dargo", "F7", "F7x", "H2", "H6", "H9", "Jolion", "M6"],
|
||||
"Honda": ["Accord", "Civic", "CR-V", "Crosstour", "Fit", "HR-V", "Insight", "Jazz", "Odyssey", "Pilot", "Ridgeline", "Stepwgn", "Vezel"],
|
||||
"Hongqi": ["E-HS9", "H5", "H7", "H9", "HS5"],
|
||||
"Hyundai": ["Accent", "Avante", "Creta", "Elantra", "Genesis", "Getz", "Grandeur", "i20", "i30", "ix35", "Kona", "Palisade", "Santa Fe", "Solaris", "Sonata", "Staria", "Tucson", "Venue"],
|
||||
"Infiniti": ["EX", "FX", "G", "JX", "Q30", "Q50", "Q60", "Q70", "QX50", "QX56", "QX60", "QX70", "QX80"],
|
||||
"Jaguar": ["E-Pace", "F-Pace", "F-Type", "I-Pace", "XE", "XF", "XJ"],
|
||||
"Jeep": ["Cherokee", "Compass", "Grand Cherokee", "Renegade", "Wrangler"],
|
||||
"Jetour": ["Dashing", "T2", "X70", "X90"],
|
||||
"KIA": ["Carens", "Carnival", "Ceed", "Cerato", "Forte", "K3", "K5", "Mohave", "Morning", "Niro", "Optima", "Picanto", "Rio", "Seltos", "Sorento", "Soul", "Sportage", "Stinger", "Telluride"],
|
||||
"LADA": ["2107", "Granta", "Kalina", "Largus", "Niva Legend", "Niva Travel", "Priora", "Vesta", "XRAY"],
|
||||
"Lexus": ["CT", "ES", "GS", "GX", "IS", "LC", "LS", "LX", "NX", "RX", "UX"],
|
||||
"LiXiang": ["L6", "L7", "L8", "L9", "MEGA"],
|
||||
"Lincoln": ["Aviator", "Continental", "Corsair", "MKC", "MKX", "Navigator"],
|
||||
"Mazda": ["2", "3", "5", "6", "Atenza", "BT-50", "CX-3", "CX-30", "CX-5", "CX-7", "CX-9", "CX-50", "MX-5"],
|
||||
"Mercedes": ["A-Class", "B-Class", "C-Class", "CLA", "CLS", "E-Class", "G-Class", "GLA", "GLB", "GLC", "GLE", "GLS", "S-Class", "Sprinter", "V-Class", "Vito"],
|
||||
"Mini": ["Clubman", "Cooper", "Countryman", "Paceman"],
|
||||
"Mitsubishi": ["ASX", "Eclipse Cross", "Galant", "L200", "Lancer", "Montero", "Outlander", "Pajero", "Pajero Sport", "RVR"],
|
||||
"Nissan": ["Almera", "Altima", "Juke", "Leaf", "Maxima", "Murano", "Navara", "Note", "Pathfinder", "Patrol", "Qashqai", "Rogue", "Sentra", "Serena", "Teana", "Terrano", "Tiida", "X-Trail"],
|
||||
"Omoda": ["C5", "E5", "S5"],
|
||||
"Opel": ["Astra", "Combo", "Corsa", "Crossland", "Insignia", "Meriva", "Mokka", "Vectra", "Zafira"],
|
||||
"Peugeot": ["2008", "206", "207", "208", "3008", "301", "307", "308", "408", "5008", "Partner", "Traveller"],
|
||||
"Porsche": ["911", "Boxster", "Cayenne", "Cayman", "Macan", "Panamera", "Taycan"],
|
||||
"Renault": ["Arkana", "Captur", "Clio", "Duster", "Fluence", "Kangoo", "Kaptur", "Koleos", "Logan", "Megane", "Sandero", "Scenic"],
|
||||
"Seat": ["Alhambra", "Arona", "Ateca", "Ibiza", "Leon", "Toledo"],
|
||||
"Skoda": ["Fabia", "Karoq", "Kodiaq", "Octavia", "Rapid", "Roomster", "Scala", "Superb", "Yeti"],
|
||||
"Smart": ["Forfour", "Fortwo"],
|
||||
"SsangYong": ["Actyon", "Korando", "Kyron", "Musso", "Rexton", "Tivoli"],
|
||||
"Subaru": ["BRZ", "Forester", "Impreza", "Legacy", "Levorg", "Outback", "Tribeca", "WRX", "XV"],
|
||||
"Suzuki": ["Baleno", "Grand Vitara", "Ignis", "Jimny", "S-Cross", "Solio", "Swift", "SX4", "Vitara"],
|
||||
"Tesla": ["Model 3", "Model S", "Model X", "Model Y"],
|
||||
"Toyota": ["4Runner", "Alphard", "Aqua", "Avalon", "Avensis", "C-HR", "Camry", "Corolla", "Crown", "Fortuner", "Harrier", "Highlander", "Hilux", "Land Cruiser", "Noah", "Prado", "Prius", "RAV4", "Sequoia", "Sienna", "Vellfire", "Venza", "Yaris"],
|
||||
"Volkswagen": ["Amarok", "Arteon", "Caddy", "Caravelle", "Golf", "Jetta", "Multivan", "Passat", "Polo", "Taos", "Teramont", "Tiguan", "Touareg", "Touran", "Transporter"],
|
||||
"Volvo": ["C30", "S40", "S60", "S80", "S90", "V40", "V60", "V90", "XC40", "XC60", "XC70", "XC90"],
|
||||
"Zeekr": ["001", "007", "009", "X"],
|
||||
"УАЗ": ["Буханка", "Патриот", "Пикап", "Хантер"],
|
||||
}
|
||||
Reference in New Issue
Block a user