seed vehicle trims catalog
This commit is contained in:
46
alembic/versions/202605120002_car_trims.py
Normal file
46
alembic/versions/202605120002_car_trims.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""car trims
|
||||||
|
|
||||||
|
Revision ID: 202605120002
|
||||||
|
Revises: 202605120001
|
||||||
|
Create Date: 2026-05-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "202605120002"
|
||||||
|
down_revision: str | None = "202605120001"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("cars", sa.Column("trim", sa.String(length=120), nullable=True))
|
||||||
|
op.create_table(
|
||||||
|
"car_trims",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("model_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("body_type", sa.String(length=60), nullable=True),
|
||||||
|
sa.Column("fuel_type", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("transmission", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("drive_type", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("year_from", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("year_to", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("market", sa.String(length=80), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["model_id"], ["car_models.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("model_id", "name", name="uq_car_trims_model_name"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_car_trims_model_id"), "car_trims", ["model_id"])
|
||||||
|
op.create_index(op.f("ix_car_trims_name"), "car_trims", ["name"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_car_trims_name"), table_name="car_trims")
|
||||||
|
op.drop_index(op.f("ix_car_trims_model_id"), table_name="car_trims")
|
||||||
|
op.drop_table("car_trims")
|
||||||
|
op.drop_column("cars", "trim")
|
||||||
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.car import CarMake
|
from app.models.car import CarMake, CarModel
|
||||||
from app.schemas.car import CarMakeRead
|
from app.schemas.car import CarMakeRead
|
||||||
|
|
||||||
router = APIRouter(prefix="/catalog", tags=["catalog"])
|
router = APIRouter(prefix="/catalog", tags=["catalog"])
|
||||||
@@ -13,9 +13,13 @@ router = APIRouter(prefix="/catalog", tags=["catalog"])
|
|||||||
@router.get("/makes", response_model=list[CarMakeRead])
|
@router.get("/makes", response_model=list[CarMakeRead])
|
||||||
async def list_makes(session: AsyncSession = Depends(get_session)) -> list[CarMake]:
|
async def list_makes(session: AsyncSession = Depends(get_session)) -> list[CarMake]:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(CarMake).options(selectinload(CarMake.models)).order_by(CarMake.name)
|
select(CarMake)
|
||||||
|
.options(selectinload(CarMake.models).selectinload(CarModel.trims))
|
||||||
|
.order_by(CarMake.name)
|
||||||
)
|
)
|
||||||
makes = list(result.scalars())
|
makes = list(result.scalars())
|
||||||
for make in makes:
|
for make in makes:
|
||||||
make.models.sort(key=lambda model: model.name)
|
make.models.sort(key=lambda model: model.name)
|
||||||
|
for model in make.models:
|
||||||
|
model.trims.sort(key=lambda trim: trim.name)
|
||||||
return makes
|
return makes
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import argparse
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@@ -7,25 +8,25 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.db.session import async_session_factory
|
from app.db.session import async_session_factory
|
||||||
from app.models.car import Car, CarMake, CarModel
|
from app.models.car import Car, CarMake, CarModel, CarTrim
|
||||||
from app.models.expense import FuelEntry, ServiceEntry, ServiceType
|
from app.models.expense import FuelEntry, ServiceEntry, ServiceType
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.catalog_data import CAR_CATALOG
|
from app.services.catalog_data import CAR_CATALOG, CAR_TRIMS, COMMON_TRIMS, MAKE_COUNTRIES
|
||||||
|
|
||||||
|
|
||||||
MOCK_PLATE_PREFIX = "MOCK"
|
MOCK_PLATE_PREFIX = "MOCK"
|
||||||
|
|
||||||
MOCK_CARS = [
|
MOCK_CARS = [
|
||||||
("KIA Sportage", "KIA", "Sportage", 2021, "gasoline", 36200, Decimal("2450000")),
|
("KIA Sportage", "KIA", "Sportage", "GT-Line 1.6T DCT AWD", 2021, "gasoline", 36200, Decimal("2450000")),
|
||||||
("Toyota Camry", "Toyota", "Camry", 2020, "gasoline", 58400, Decimal("2850000")),
|
("Toyota Camry", "Toyota", "Camry", "Comfort 2.5 AT", 2020, "gasoline", 58400, Decimal("2850000")),
|
||||||
("Hyundai Tucson", "Hyundai", "Tucson", 2022, "gasoline", 27100, Decimal("2750000")),
|
("Hyundai Tucson", "Hyundai", "Tucson", "Prestige 2.0 AT AWD", 2022, "gasoline", 27100, Decimal("2750000")),
|
||||||
("Volkswagen Tiguan", "Volkswagen", "Tiguan", 2019, "gasoline", 73400, Decimal("2300000")),
|
("Volkswagen Tiguan", "Volkswagen", "Tiguan", "Status 2.0 TSI DSG 4Motion", 2019, "gasoline", 73400, Decimal("2300000")),
|
||||||
("BMW X3", "BMW", "X3", 2021, "diesel", 48900, Decimal("4350000")),
|
("BMW X3", "BMW", "X3", "20d xDrive", 2021, "diesel", 48900, Decimal("4350000")),
|
||||||
("Mercedes GLC", "Mercedes", "GLC", 2020, "gasoline", 52200, Decimal("4500000")),
|
("Mercedes GLC", "Mercedes", "GLC", "GLC 300 4MATIC AMG Line", 2020, "gasoline", 52200, Decimal("4500000")),
|
||||||
("Nissan X-Trail", "Nissan", "X-Trail", 2018, "gasoline", 91400, Decimal("1850000")),
|
("Nissan X-Trail", "Nissan", "X-Trail", "Premium", 2018, "gasoline", 91400, Decimal("1850000")),
|
||||||
("Skoda Octavia", "Skoda", "Octavia", 2021, "gasoline", 46800, Decimal("2050000")),
|
("Skoda Octavia", "Skoda", "Octavia", "Comfort", 2021, "gasoline", 46800, Decimal("2050000")),
|
||||||
("Tesla Model 3", "Tesla", "Model 3", 2022, "electric", 33800, Decimal("3900000")),
|
("Tesla Model 3", "Tesla", "Model 3", "Long Range AWD", 2022, "electric", 33800, Decimal("3900000")),
|
||||||
("Haval Jolion", "Haval", "Jolion", 2023, "gasoline", 19600, Decimal("2150000")),
|
("Haval Jolion", "Haval", "Jolion", "Premium", 2023, "gasoline", 19600, Decimal("2150000")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -43,15 +44,40 @@ async def seed_catalog(session: AsyncSession) -> None:
|
|||||||
for make_name, model_names in CAR_CATALOG.items():
|
for make_name, model_names in CAR_CATALOG.items():
|
||||||
make = existing.get(make_name)
|
make = existing.get(make_name)
|
||||||
if make is None:
|
if make is None:
|
||||||
make = CarMake(name=make_name)
|
make = CarMake(name=make_name, country=MAKE_COUNTRIES.get(make_name))
|
||||||
session.add(make)
|
session.add(make)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
existing_models = set()
|
existing_models = set()
|
||||||
else:
|
else:
|
||||||
|
if not make.country and MAKE_COUNTRIES.get(make_name):
|
||||||
|
make.country = MAKE_COUNTRIES[make_name]
|
||||||
existing_models = {model.name for model in make.models}
|
existing_models = {model.name for model in make.models}
|
||||||
for model_name in model_names:
|
for model_name in model_names:
|
||||||
if model_name not in existing_models:
|
if model_name not in existing_models:
|
||||||
session.add(CarModel(make_id=make.id, name=model_name))
|
session.add(CarModel(make_id=make.id, name=model_name))
|
||||||
|
await session.flush()
|
||||||
|
await seed_trims(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_trims(session: AsyncSession) -> None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(CarMake).options(selectinload(CarMake.models).selectinload(CarModel.trims))
|
||||||
|
)
|
||||||
|
makes = {make.name: make for make in result.scalars()}
|
||||||
|
for make in makes.values():
|
||||||
|
for model in make.models:
|
||||||
|
trim_rows = CAR_TRIMS.get((make.name, model.name)) or [
|
||||||
|
{**item, "body_type": infer_body_type(model.name)} for item in COMMON_TRIMS
|
||||||
|
]
|
||||||
|
existing = {trim.name for trim in model.trims}
|
||||||
|
for row in trim_rows:
|
||||||
|
if row["name"] not in existing:
|
||||||
|
session.add(CarTrim(model_id=model.id, **row))
|
||||||
|
|
||||||
|
|
||||||
|
def infer_body_type(model_name: str) -> str:
|
||||||
|
suv_markers = ("Q", "X", "CX", "CR-V", "RAV", "Tiguan", "Tucson", "Sportage", "Jolion")
|
||||||
|
return "SUV" if any(marker in model_name for marker in suv_markers) else "sedan"
|
||||||
|
|
||||||
|
|
||||||
async def pick_owner(session: AsyncSession) -> User:
|
async def pick_owner(session: AsyncSession) -> User:
|
||||||
@@ -82,12 +108,16 @@ async def seed_mock_usage(session: AsyncSession, owner: User) -> None:
|
|||||||
await clear_previous_mock(session)
|
await clear_previous_mock(session)
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|
||||||
for index, (name, make, model, year, fuel_type, start_odo, price) in enumerate(MOCK_CARS, start=1):
|
for index, (name, make, model, trim, year, fuel_type, start_odo, price) in enumerate(
|
||||||
|
MOCK_CARS,
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
car = Car(
|
car = Car(
|
||||||
owner_id=owner.id,
|
owner_id=owner.id,
|
||||||
name=name,
|
name=name,
|
||||||
make=make,
|
make=make,
|
||||||
model=model,
|
model=model,
|
||||||
|
trim=trim,
|
||||||
year=year,
|
year=year,
|
||||||
plate_number=f"{MOCK_PLATE_PREFIX}-{index:02d}",
|
plate_number=f"{MOCK_PLATE_PREFIX}-{index:02d}",
|
||||||
fuel_type=fuel_type,
|
fuel_type=fuel_type,
|
||||||
@@ -201,9 +231,13 @@ async def seed_mock_usage(session: AsyncSession, owner: User) -> None:
|
|||||||
car.current_odometer = odometer
|
car.current_odometer = odometer
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main(catalog_only: bool = False) -> None:
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
await seed_catalog(session)
|
await seed_catalog(session)
|
||||||
|
if catalog_only:
|
||||||
|
await session.commit()
|
||||||
|
print("Seeded vehicle catalog")
|
||||||
|
return
|
||||||
owner = await pick_owner(session)
|
owner = await pick_owner(session)
|
||||||
await seed_mock_usage(session, owner)
|
await seed_mock_usage(session, owner)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -211,4 +245,7 @@ async def main() -> None:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--catalog-only", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(main(catalog_only=args.catalog_only))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, UniqueConstraint, func
|
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -15,6 +15,7 @@ class Car(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(160))
|
name: Mapped[str] = mapped_column(String(160))
|
||||||
make: Mapped[str | None] = mapped_column(String(80))
|
make: Mapped[str | None] = mapped_column(String(80))
|
||||||
model: Mapped[str | None] = mapped_column(String(80))
|
model: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
trim: Mapped[str | None] = mapped_column(String(120))
|
||||||
year: Mapped[int | None]
|
year: Mapped[int | None]
|
||||||
plate_number: Mapped[str | None] = mapped_column(String(32))
|
plate_number: Mapped[str | None] = mapped_column(String(32))
|
||||||
vin: Mapped[str | None] = mapped_column(String(32))
|
vin: Mapped[str | None] = mapped_column(String(32))
|
||||||
@@ -53,3 +54,23 @@ class CarModel(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
make = relationship("CarMake", back_populates="models")
|
make = relationship("CarMake", back_populates="models")
|
||||||
|
trims = relationship("CarTrim", back_populates="model", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class CarTrim(Base):
|
||||||
|
__tablename__ = "car_trims"
|
||||||
|
__table_args__ = (UniqueConstraint("model_id", "name", name="uq_car_trims_model_name"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
model_id: Mapped[int] = mapped_column(ForeignKey("car_models.id", ondelete="CASCADE"), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(120), index=True)
|
||||||
|
body_type: Mapped[str | None] = mapped_column(String(60))
|
||||||
|
fuel_type: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
transmission: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
drive_type: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
year_from: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
year_to: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
market: Mapped[str | None] = mapped_column(String(80))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
model = relationship("CarModel", back_populates="trims")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class CarBase(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
make: str | None = None
|
make: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
trim: str | None = None
|
||||||
year: int | None = None
|
year: int | None = None
|
||||||
plate_number: str | None = None
|
plate_number: str | None = None
|
||||||
vin: str | None = None
|
vin: str | None = None
|
||||||
@@ -25,6 +26,7 @@ class CarUpdate(BaseModel):
|
|||||||
name: str | None = None
|
name: str | None = None
|
||||||
make: str | None = None
|
make: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
trim: str | None = None
|
||||||
year: int | None = None
|
year: int | None = None
|
||||||
plate_number: str | None = None
|
plate_number: str | None = None
|
||||||
vin: str | None = None
|
vin: str | None = None
|
||||||
@@ -42,9 +44,24 @@ class CarRead(CarBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CarTrimRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
body_type: str | None = None
|
||||||
|
fuel_type: str | None = None
|
||||||
|
transmission: str | None = None
|
||||||
|
drive_type: str | None = None
|
||||||
|
year_from: int | None = None
|
||||||
|
year_to: int | None = None
|
||||||
|
market: str | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class CarModelRead(BaseModel):
|
class CarModelRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
trims: list[CarTrimRead] = []
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -55,3 +55,103 @@ CAR_CATALOG: dict[str, list[str]] = {
|
|||||||
"Zeekr": ["001", "007", "009", "X"],
|
"Zeekr": ["001", "007", "009", "X"],
|
||||||
"УАЗ": ["Буханка", "Патриот", "Пикап", "Хантер"],
|
"УАЗ": ["Буханка", "Патриот", "Пикап", "Хантер"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MAKE_COUNTRIES: dict[str, str] = {
|
||||||
|
"BMW": "Germany",
|
||||||
|
"Mercedes": "Germany",
|
||||||
|
"Volkswagen": "Germany",
|
||||||
|
"Audi": "Germany",
|
||||||
|
"Porsche": "Germany",
|
||||||
|
"Skoda": "Czech Republic",
|
||||||
|
"Toyota": "Japan",
|
||||||
|
"Lexus": "Japan",
|
||||||
|
"Nissan": "Japan",
|
||||||
|
"Honda": "Japan",
|
||||||
|
"Mazda": "Japan",
|
||||||
|
"Subaru": "Japan",
|
||||||
|
"Mitsubishi": "Japan",
|
||||||
|
"Hyundai": "South Korea",
|
||||||
|
"KIA": "South Korea",
|
||||||
|
"Genesis": "South Korea",
|
||||||
|
"Tesla": "USA",
|
||||||
|
"Ford": "USA",
|
||||||
|
"Chevrolet": "USA",
|
||||||
|
"Haval": "China",
|
||||||
|
"Chery": "China",
|
||||||
|
"Geely": "China",
|
||||||
|
"BYD": "China",
|
||||||
|
"LADA": "Russia",
|
||||||
|
"УАЗ": "Russia",
|
||||||
|
}
|
||||||
|
|
||||||
|
COMMON_TRIMS = [
|
||||||
|
{
|
||||||
|
"name": "Base",
|
||||||
|
"body_type": None,
|
||||||
|
"fuel_type": "gasoline",
|
||||||
|
"transmission": "AT",
|
||||||
|
"drive_type": "FWD",
|
||||||
|
"market": "Global",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Comfort",
|
||||||
|
"body_type": None,
|
||||||
|
"fuel_type": "gasoline",
|
||||||
|
"transmission": "AT",
|
||||||
|
"drive_type": "FWD",
|
||||||
|
"market": "Global",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Premium",
|
||||||
|
"body_type": None,
|
||||||
|
"fuel_type": "gasoline",
|
||||||
|
"transmission": "AT",
|
||||||
|
"drive_type": "AWD",
|
||||||
|
"market": "Global",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
CAR_TRIMS: dict[tuple[str, str], list[dict[str, str | int | None]]] = {
|
||||||
|
("Toyota", "Camry"): [
|
||||||
|
{"name": "Standard 2.0 AT", "body_type": "sedan", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "FWD", "year_from": 2018, "year_to": 2024, "market": "Global"},
|
||||||
|
{"name": "Comfort 2.5 AT", "body_type": "sedan", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "FWD", "year_from": 2018, "year_to": 2024, "market": "Global"},
|
||||||
|
{"name": "Prestige 2.5 AT", "body_type": "sedan", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "FWD", "year_from": 2018, "year_to": 2024, "market": "Global"},
|
||||||
|
{"name": "Hybrid 2.5 e-CVT", "body_type": "sedan", "fuel_type": "hybrid", "transmission": "e-CVT", "drive_type": "FWD", "year_from": 2018, "year_to": 2025, "market": "Global"},
|
||||||
|
],
|
||||||
|
("Toyota", "RAV4"): [
|
||||||
|
{"name": "Comfort 2.0 CVT", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "CVT", "drive_type": "FWD", "year_from": 2019, "year_to": 2025, "market": "Global"},
|
||||||
|
{"name": "Style 2.0 CVT AWD", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "CVT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"},
|
||||||
|
{"name": "Hybrid 2.5 e-CVT AWD", "body_type": "SUV", "fuel_type": "hybrid", "transmission": "e-CVT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"},
|
||||||
|
],
|
||||||
|
("KIA", "Sportage"): [
|
||||||
|
{"name": "Classic 2.0 AT", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "FWD", "year_from": 2018, "year_to": 2025, "market": "Global"},
|
||||||
|
{"name": "Comfort 2.0 AT AWD", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2018, "year_to": 2025, "market": "Global"},
|
||||||
|
{"name": "Prestige 2.0 Diesel AWD", "body_type": "SUV", "fuel_type": "diesel", "transmission": "AT", "drive_type": "AWD", "year_from": 2018, "year_to": 2024, "market": "Global"},
|
||||||
|
{"name": "GT-Line 1.6T DCT AWD", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "DCT", "drive_type": "AWD", "year_from": 2021, "year_to": 2025, "market": "Global"},
|
||||||
|
],
|
||||||
|
("Hyundai", "Tucson"): [
|
||||||
|
{"name": "Lifestyle 2.0 AT", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "FWD", "year_from": 2019, "year_to": 2025, "market": "Global"},
|
||||||
|
{"name": "Prestige 2.0 AT AWD", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"},
|
||||||
|
{"name": "Hybrid 1.6T AWD", "body_type": "SUV", "fuel_type": "hybrid", "transmission": "AT", "drive_type": "AWD", "year_from": 2021, "year_to": 2025, "market": "Global"},
|
||||||
|
],
|
||||||
|
("Volkswagen", "Tiguan"): [
|
||||||
|
{"name": "Respect 1.4 TSI DSG", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "DSG", "drive_type": "FWD", "year_from": 2017, "year_to": 2024, "market": "Global"},
|
||||||
|
{"name": "Status 2.0 TSI DSG 4Motion", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "DSG", "drive_type": "AWD", "year_from": 2017, "year_to": 2024, "market": "Global"},
|
||||||
|
{"name": "R-Line 2.0 TSI DSG 4Motion", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "DSG", "drive_type": "AWD", "year_from": 2017, "year_to": 2024, "market": "Global"},
|
||||||
|
],
|
||||||
|
("BMW", "X3"): [
|
||||||
|
{"name": "20i xDrive", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2018, "year_to": 2025, "market": "Global"},
|
||||||
|
{"name": "20d xDrive", "body_type": "SUV", "fuel_type": "diesel", "transmission": "AT", "drive_type": "AWD", "year_from": 2018, "year_to": 2025, "market": "Global"},
|
||||||
|
{"name": "30i xDrive M Sport", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2018, "year_to": 2025, "market": "Global"},
|
||||||
|
],
|
||||||
|
("Mercedes", "GLC"): [
|
||||||
|
{"name": "GLC 200 4MATIC", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"},
|
||||||
|
{"name": "GLC 220d 4MATIC", "body_type": "SUV", "fuel_type": "diesel", "transmission": "AT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"},
|
||||||
|
{"name": "GLC 300 4MATIC AMG Line", "body_type": "SUV", "fuel_type": "gasoline", "transmission": "AT", "drive_type": "AWD", "year_from": 2019, "year_to": 2025, "market": "Global"},
|
||||||
|
],
|
||||||
|
("Tesla", "Model 3"): [
|
||||||
|
{"name": "Rear-Wheel Drive", "body_type": "sedan", "fuel_type": "electric", "transmission": "single-speed", "drive_type": "RWD", "year_from": 2019, "year_to": 2026, "market": "Global"},
|
||||||
|
{"name": "Long Range AWD", "body_type": "sedan", "fuel_type": "electric", "transmission": "single-speed", "drive_type": "AWD", "year_from": 2019, "year_to": 2026, "market": "Global"},
|
||||||
|
{"name": "Performance AWD", "body_type": "sedan", "fuel_type": "electric", "transmission": "single-speed", "drive_type": "AWD", "year_from": 2019, "year_to": 2026, "market": "Global"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -266,10 +266,28 @@
|
|||||||
Модель
|
Модель
|
||||||
<select name="model" id="modelSelect" required></select>
|
<select name="model" id="modelSelect" required></select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Комплектация
|
||||||
|
<select name="trim" id="trimSelect"></select>
|
||||||
|
</label>
|
||||||
|
<div class="catalog-preview" id="catalogPreview">
|
||||||
|
<strong>Выбери модель</strong>
|
||||||
|
<span>Покажем кузов, топливо, привод и годы выпуска.</span>
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
Год
|
Год
|
||||||
<input name="year" type="number" min="1900" max="2100" />
|
<input name="year" type="number" min="1900" max="2100" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Тип топлива
|
||||||
|
<select name="fuel_type" id="fuelTypeSelect">
|
||||||
|
<option value="">Авто</option>
|
||||||
|
<option value="gasoline">Бензин</option>
|
||||||
|
<option value="diesel">Дизель</option>
|
||||||
|
<option value="hybrid">Гибрид</option>
|
||||||
|
<option value="electric">Электро</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Добавить авто</button>
|
<button type="submit">Добавить авто</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -614,11 +614,50 @@ async function loadCatalog() {
|
|||||||
function initCarCatalog() {
|
function initCarCatalog() {
|
||||||
const makeSelect = document.querySelector("#makeSelect");
|
const makeSelect = document.querySelector("#makeSelect");
|
||||||
const modelSelect = document.querySelector("#modelSelect");
|
const modelSelect = document.querySelector("#modelSelect");
|
||||||
|
const trimSelect = document.querySelector("#trimSelect");
|
||||||
|
const fuelTypeSelect = document.querySelector("#fuelTypeSelect");
|
||||||
|
const preview = document.querySelector("#catalogPreview");
|
||||||
const makes = [...state.catalog].sort((a, b) => a.name.localeCompare(b.name, "ru"));
|
const makes = [...state.catalog].sort((a, b) => a.name.localeCompare(b.name, "ru"));
|
||||||
makeSelect.innerHTML = `<option value="">${t("Выбери марку")}</option>` + makes
|
makeSelect.innerHTML = `<option value="">${t("Выбери марку")}</option>` + makes
|
||||||
.map((make) => `<option value="${make.name}">${make.name}</option>`)
|
.map((make) => `<option value="${make.name}">${make.name}</option>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
|
function selectedModel() {
|
||||||
|
const make = state.catalog.find((item) => item.name === makeSelect.value);
|
||||||
|
return make?.models.find((model) => model.name === modelSelect.value) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPreview() {
|
||||||
|
const model = selectedModel();
|
||||||
|
const trim = model?.trims?.find((item) => item.name === trimSelect.value);
|
||||||
|
if (!model) {
|
||||||
|
preview.innerHTML = `<strong>${t("Выбери модель")}</strong><span>Покажем кузов, топливо, привод и годы выпуска.</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chips = [
|
||||||
|
trim?.body_type,
|
||||||
|
trim?.fuel_type,
|
||||||
|
trim?.transmission,
|
||||||
|
trim?.drive_type,
|
||||||
|
trim?.year_from && trim?.year_to ? `${trim.year_from}-${trim.year_to}` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
preview.innerHTML = `
|
||||||
|
<strong>${makeSelect.value} ${model.name}${trim ? ` · ${trim.name}` : ""}</strong>
|
||||||
|
<span>${chips.length ? chips.join(" · ") : "Базовые параметры можно уточнить позже"}</span>
|
||||||
|
`;
|
||||||
|
if (trim?.fuel_type && !fuelTypeSelect.value) fuelTypeSelect.value = trim.fuel_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTrims() {
|
||||||
|
const model = selectedModel();
|
||||||
|
const trims = model?.trims || [];
|
||||||
|
trimSelect.disabled = !trims.length;
|
||||||
|
trimSelect.innerHTML = trims.length
|
||||||
|
? `<option value="">Комплектация не выбрана</option>` + trims.map((trim) => `<option value="${trim.name}">${trim.name}</option>`).join("")
|
||||||
|
: `<option value="">Сначала модель</option>`;
|
||||||
|
syncPreview();
|
||||||
|
}
|
||||||
|
|
||||||
function syncModels() {
|
function syncModels() {
|
||||||
const make = makeSelect.value;
|
const make = makeSelect.value;
|
||||||
const models = state.catalog.find((item) => item.name === make)?.models || [];
|
const models = state.catalog.find((item) => item.name === make)?.models || [];
|
||||||
@@ -626,24 +665,32 @@ function initCarCatalog() {
|
|||||||
modelSelect.innerHTML = models.length
|
modelSelect.innerHTML = models.length
|
||||||
? `<option value="">${t("Выбери модель")}</option>` + models.map((model) => `<option value="${model.name}">${model.name}</option>`).join("")
|
? `<option value="">${t("Выбери модель")}</option>` + models.map((model) => `<option value="${model.name}">${model.name}</option>`).join("")
|
||||||
: `<option value="">${t("Сначала марка")}</option>`;
|
: `<option value="">${t("Сначала марка")}</option>`;
|
||||||
|
syncTrims();
|
||||||
}
|
}
|
||||||
|
|
||||||
makeSelect.addEventListener("change", syncModels);
|
makeSelect.addEventListener("change", syncModels);
|
||||||
|
modelSelect.addEventListener("change", syncTrims);
|
||||||
|
trimSelect.addEventListener("change", syncPreview);
|
||||||
syncModels();
|
syncModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetCarCatalog() {
|
function resetCarCatalog() {
|
||||||
document.querySelector("#makeSelect").value = "";
|
document.querySelector("#makeSelect").value = "";
|
||||||
const modelSelect = document.querySelector("#modelSelect");
|
const modelSelect = document.querySelector("#modelSelect");
|
||||||
|
const trimSelect = document.querySelector("#trimSelect");
|
||||||
modelSelect.disabled = true;
|
modelSelect.disabled = true;
|
||||||
modelSelect.innerHTML = `<option value="">${t("Сначала марка")}</option>`;
|
modelSelect.innerHTML = `<option value="">${t("Сначала марка")}</option>`;
|
||||||
|
trimSelect.disabled = true;
|
||||||
|
trimSelect.innerHTML = `<option value="">Сначала модель</option>`;
|
||||||
|
document.querySelector("#catalogPreview").innerHTML =
|
||||||
|
`<strong>${t("Выбери модель")}</strong><span>Покажем кузов, топливо, привод и годы выпуска.</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHero(stats) {
|
function updateHero(stats) {
|
||||||
const car = selectedCar();
|
const car = selectedCar();
|
||||||
document.querySelector("#selectedCarTitle").textContent = car?.name || t("Не выбран");
|
document.querySelector("#selectedCarTitle").textContent = car?.name || t("Не выбран");
|
||||||
document.querySelector("#selectedCarMeta").textContent = car
|
document.querySelector("#selectedCarMeta").textContent = car
|
||||||
? [car.make, car.model, car.year].filter(Boolean).join(" ") || t("Без деталей")
|
? [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || t("Без деталей")
|
||||||
: t("Добавь авто или выбери из списка");
|
: t("Добавь авто или выбери из списка");
|
||||||
document.querySelector("#summaryTotal").textContent = money(stats?.total_cost);
|
document.querySelector("#summaryTotal").textContent = money(stats?.total_cost);
|
||||||
document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km
|
document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km
|
||||||
@@ -670,7 +717,7 @@ function renderCars() {
|
|||||||
<span class="car-badge">${(car.make || car.name).slice(0, 2).toUpperCase()}</span>
|
<span class="car-badge">${(car.make || car.name).slice(0, 2).toUpperCase()}</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>${car.name}</strong>
|
<strong>${car.name}</strong>
|
||||||
<small>${[car.make, car.model, car.year].filter(Boolean).join(" ") || t("Без деталей")}</small>
|
<small>${[car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || t("Без деталей")}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
`,
|
`,
|
||||||
@@ -1084,7 +1131,9 @@ document.querySelector("#carForm").addEventListener("submit", async (event) => {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
make: data.make || null,
|
make: data.make || null,
|
||||||
model: data.model || null,
|
model: data.model || null,
|
||||||
|
trim: data.trim || null,
|
||||||
year: data.year ? Number(data.year) : null,
|
year: data.year ? Number(data.year) : null,
|
||||||
|
fuel_type: data.fuel_type || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|||||||
@@ -1199,6 +1199,28 @@ select {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-preview {
|
||||||
|
align-self: stretch;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(18, 115, 95, 0.08), rgba(47, 111, 159, 0.08)),
|
||||||
|
#fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-preview strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-preview span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes toastIn {
|
@keyframes toastIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user