feat(T02): vehicle management + mobile.de push + vehicle UI

- Vehicle model: 25+ fields, 5 vehicle types, soft-delete
- Vehicle CRUD: 7 API endpoints with JWT auth, filter/sort/paginate
- mobile.de: push/update/delete listings, field mapping, retry logic
- MobileDeListing model for sync status tracking
- Frontend: VehicleList, VehicleForm, VehicleDetail, MobileDeStatus
- 73 backend tests (82% coverage), 16 frontend tests
This commit is contained in:
2026-07-14 12:03:19 +02:00
parent d89304845a
commit 74b5e6afb8
24 changed files with 3847 additions and 97 deletions
+239
View File
@@ -0,0 +1,239 @@
"""SQLAlchemy models for vehicles and mobile.de listings."""
import enum
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
CheckConstraint,
Date,
DateTime,
Enum,
ForeignKey,
Integer,
Numeric,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class VehicleCondition(str, enum.Enum):
new = "new"
used = "used"
class VehicleAvailability(str, enum.Enum):
available = "available"
reserved = "reserved"
sold = "sold"
class VehicleType(str, enum.Enum):
lkw = "lkw"
pkw = "pkw"
baumaschine = "baumaschine"
stapler = "stapler"
transporter = "transporter"
class SyncStatus(str, enum.Enum):
pending = "pending"
synced = "synced"
fehler = "fehler"
class Vehicle(Base):
"""Vehicle entity for the ERP inventory."""
__tablename__ = "vehicles"
__table_args__ = (
CheckConstraint(
"char_length(fin) = 17", name="ck_vehicles_fin_length"
),
CheckConstraint(
"condition IN ('new', 'used')", name="ck_vehicles_condition"
),
CheckConstraint(
"availability IN ('available', 'reserved', 'sold')",
name="ck_vehicles_availability",
),
CheckConstraint(
"vehicle_type IN ('lkw', 'pkw', 'baumaschine', 'stapler', 'transporter')",
name="ck_vehicles_vehicle_type",
),
CheckConstraint(
"operating_hours_unit IS NULL OR operating_hours_unit IN ('h', 'min')",
name="ck_vehicles_operating_hours_unit",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
make: Mapped[str] = mapped_column(String(100), nullable=False)
model: Mapped[str] = mapped_column(String(100), nullable=False)
fin: Mapped[str] = mapped_column(
String(17), unique=True, nullable=False, index=True
)
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
first_registration: Mapped[date | None] = mapped_column(
Date, nullable=True
)
power_kw: Mapped[int | None] = mapped_column(Integer, nullable=True)
power_hp: Mapped[int | None] = mapped_column(Integer, nullable=True)
fuel_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
transmission: Mapped[str | None] = mapped_column(String(20), nullable=True)
color: Mapped[str | None] = mapped_column(String(50), nullable=True)
condition: Mapped[str] = mapped_column(
String(20), nullable=False, default="used"
)
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
availability: Mapped[str] = mapped_column(
String(20), nullable=False, default="available"
)
price: Mapped[Decimal] = mapped_column(
Numeric(12, 2), nullable=False
)
vehicle_type: Mapped[str] = mapped_column(String(20), nullable=False)
lkw_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
machine_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
body_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
operating_hours: Mapped[Decimal | None] = mapped_column(
Numeric(12, 1), nullable=True
)
operating_hours_unit: Mapped[str | None] = mapped_column(
String(5), nullable=True
)
mileage_km: Mapped[int | None] = mapped_column(Integer, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
mobile_de_listings: Mapped[list["MobileDeListing"]] = relationship(
back_populates="vehicle", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Vehicle id={self.id} fin={self.fin} make={self.make}>"
def to_dict(self) -> dict:
"""Serialize vehicle for API responses."""
return {
"id": str(self.id),
"make": self.make,
"model": self.model,
"fin": self.fin,
"year": self.year,
"first_registration": (
self.first_registration.isoformat()
if self.first_registration
else None
),
"power_kw": self.power_kw,
"power_hp": self.power_hp,
"fuel_type": self.fuel_type,
"transmission": self.transmission,
"color": self.color,
"condition": self.condition,
"location": self.location,
"availability": self.availability,
"price": float(self.price) if self.price is not None else None,
"vehicle_type": self.vehicle_type,
"lkw_type": self.lkw_type,
"machine_type": self.machine_type,
"body_type": self.body_type,
"operating_hours": (
float(self.operating_hours)
if self.operating_hours is not None
else None
),
"operating_hours_unit": self.operating_hours_unit,
"mileage_km": self.mileage_km,
"description": self.description,
"created_at": (
self.created_at.isoformat() if self.created_at else None
),
"updated_at": (
self.updated_at.isoformat() if self.updated_at else None
),
"deleted_at": (
self.deleted_at.isoformat() if self.deleted_at else None
),
}
class MobileDeListing(Base):
"""mobile.de listing sync tracking for a vehicle."""
__tablename__ = "mobile_de_listings"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
vehicle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("vehicles.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
ad_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
sync_status: Mapped[str] = mapped_column(
String(20), nullable=False, default="pending"
)
synced_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
error_log: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
vehicle: Mapped["Vehicle"] = relationship(back_populates="mobile_de_listings")
def __repr__(self) -> str:
return f"<MobileDeListing id={self.id} vehicle_id={self.vehicle_id} status={self.sync_status}>"
def to_dict(self) -> dict:
"""Serialize listing for API responses."""
return {
"id": str(self.id),
"vehicle_id": str(self.vehicle_id),
"ad_id": self.ad_id,
"sync_status": self.sync_status,
"synced_at": (
self.synced_at.isoformat() if self.synced_at else None
),
"error_log": self.error_log,
"created_at": (
self.created_at.isoformat() if self.created_at else None
),
"updated_at": (
self.updated_at.isoformat() if self.updated_at else None
),
}