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:
@@ -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
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user