seed vehicle trims catalog

This commit is contained in:
VPN SaaS Dev
2026-05-12 04:44:19 +09:00
parent f7a3b8be54
commit b5012ec6e7
9 changed files with 335 additions and 21 deletions

View 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")

View File

@@ -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

View File

@@ -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))

View File

@@ -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")

View File

@@ -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)

View File

@@ -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"},
],
}

View File

@@ -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>

View File

@@ -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();

View File

@@ -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;