533 lines
26 KiB
Python
Executable File
533 lines
26 KiB
Python
Executable File
# /opt/docker/dev/service_finder/backend/app/schemas/asset.py
|
|
from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator, field_validator
|
|
from typing import Optional, Dict, Any, List
|
|
from uuid import UUID
|
|
from datetime import datetime, date
|
|
|
|
class AssetCatalogResponse(BaseModel):
|
|
""" A technikai katalógus (Master Data) teljes adattartalma. """
|
|
id: int
|
|
make: str
|
|
model: str
|
|
generation: Optional[str] = None
|
|
engine_variant: Optional[str] = None
|
|
year_from: Optional[int] = None
|
|
year_to: Optional[int] = None
|
|
vehicle_class: Optional[str] = None
|
|
fuel_type: Optional[str] = None
|
|
|
|
# Technikai paraméterek az automatizáláshoz
|
|
power_kw: Optional[int] = None
|
|
engine_capacity: Optional[int] = None
|
|
max_weight_kg: Optional[int] = None
|
|
axle_count: Optional[int] = None
|
|
euro_class: Optional[str] = None
|
|
body_type: Optional[str] = None
|
|
engine_code: Optional[str] = None
|
|
|
|
factory_data: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
class AssetResponse(BaseModel):
|
|
""" A konkrét járműpéldány (Asset) teljes válaszmodellje - Thick Digital Twin. """
|
|
# === IDENTIFICATION ===
|
|
id: UUID
|
|
vin: Optional[str] = Field(None, min_length=1, max_length=50)
|
|
license_plate: Optional[str] = None
|
|
name: Optional[str] = None
|
|
catalog_id: Optional[int] = None
|
|
|
|
# === CLASSIFICATION ===
|
|
vehicle_class: Optional[str] = None
|
|
brand: Optional[str] = None
|
|
model: Optional[str] = None
|
|
trim_level: Optional[str] = None
|
|
|
|
# === TECHNICAL SPECS ===
|
|
fuel_type: Optional[str] = None
|
|
engine_capacity: Optional[int] = None
|
|
power_kw: Optional[int] = None
|
|
torque_nm: Optional[int] = None
|
|
cylinder_layout: Optional[str] = None
|
|
transmission_type: Optional[str] = None
|
|
drive_type: Optional[str] = None
|
|
euro_classification: Optional[str] = None
|
|
|
|
# === PHYSICAL DIMENSIONS ===
|
|
curb_weight: Optional[int] = None
|
|
max_weight: Optional[int] = None
|
|
cargo_volume_x: Optional[float] = None
|
|
cargo_volume_y: Optional[float] = None
|
|
door_count: Optional[int] = None
|
|
seat_count: Optional[int] = None
|
|
|
|
# === EQUIPMENT ===
|
|
roof_type: Optional[str] = None
|
|
audio_system_type: Optional[str] = None
|
|
individual_equipment: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
# === STATUS ===
|
|
current_mileage: int = Field(default=0)
|
|
condition_score: int = Field(default=100)
|
|
status: str
|
|
data_status: Optional[str] = None
|
|
is_verified: bool
|
|
verification_method: Optional[str] = None
|
|
catalog_match_score: Optional[float] = None
|
|
|
|
# === TIMELINE ===
|
|
year_of_manufacture: Optional[int] = None
|
|
first_registration_date: Optional[datetime] = None
|
|
created_at: datetime
|
|
updated_at: Optional[datetime] = None
|
|
|
|
# === WARRANTY ===
|
|
is_under_warranty: bool = Field(default=False, description="Érvényes jótállás")
|
|
warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma")
|
|
|
|
# === REGISTRATION DOCUMENTS ===
|
|
registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma")
|
|
registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje")
|
|
vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma")
|
|
|
|
# === INTERNATIONALIZATION (ÚJ) ===
|
|
registration_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')")
|
|
first_domestic_registration_date: Optional[date] = Field(None, description="Első belföldi forgalomba helyezés dátuma")
|
|
import_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Importország (ISO 3166-1 alpha-2), ha importált jármű")
|
|
title_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv szám (title document)")
|
|
engine_number: Optional[str] = Field(None, max_length=100, description="Motorszám")
|
|
number_of_previous_owners: Optional[int] = Field(None, ge=0, description="Előző tulajdonosok száma")
|
|
|
|
# === SALES MODULE ===
|
|
is_for_sale: bool = Field(default=False)
|
|
price: Optional[float] = None
|
|
currency: str = Field(default="EUR")
|
|
|
|
# === ORGANIZATION & LOCATION ===
|
|
current_organization_id: Optional[int] = None
|
|
branch_id: Optional[UUID] = None
|
|
relocation_performed: bool = Field(default=False)
|
|
|
|
# === IDENTITY RELATIONSHIPS ===
|
|
owner_organization_id: Optional[int] = None
|
|
operator_person_id: Optional[int] = None
|
|
owner_person_id: Optional[int] = None
|
|
operator_org_id: Optional[int] = None
|
|
|
|
# === CATALOG RELATIONSHIP ===
|
|
catalog: Optional[AssetCatalogResponse] = None
|
|
|
|
# === PROFILE COMPLETION ===
|
|
profile_completion_percentage: int = Field(default=0, ge=0, le=100)
|
|
|
|
# === PRIMARY VEHICLE FLAG ===
|
|
is_primary: bool = Field(default=False, description="Elsődleges jármű (tárolva: individual_equipment JSONB)")
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class AssetCreate(BaseModel):
|
|
""" Jármű létrehozásához szükséges adatok - Thick Digital Twin támogatással. """
|
|
# === CORE IDENTIFICATION (Required for status determination) ===
|
|
license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
|
|
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám (opcionális)")
|
|
|
|
# ── Pre-validator: convert empty strings to None for optional string fields ──
|
|
@validator('license_plate', 'vin', 'trim_level',
|
|
'cylinder_layout', 'transmission_type', 'drive_type',
|
|
'euro_classification', 'roof_type', 'audio_system_type',
|
|
'data_status',
|
|
pre=True, always=True)
|
|
def empty_str_to_none(cls, v):
|
|
"""Convert empty strings to None so Pydantic doesn't reject them with min_length."""
|
|
if v is not None and isinstance(v, str) and v.strip() == '':
|
|
return None
|
|
return v
|
|
|
|
# ── Data normalization validators ──
|
|
@field_validator('brand')
|
|
@classmethod
|
|
def normalize_brand(cls, v: Optional[str]) -> Optional[str]:
|
|
"""Brand: UPPERCASE, strip whitespace."""
|
|
if v is None or not isinstance(v, str):
|
|
return v
|
|
return v.strip().upper()
|
|
|
|
@field_validator('model')
|
|
@classmethod
|
|
def normalize_model(cls, v: Optional[str]) -> Optional[str]:
|
|
"""Model: UPPERCASE, remove ALL spaces."""
|
|
if v is None or not isinstance(v, str):
|
|
return v
|
|
return v.upper().replace(' ', '')
|
|
|
|
# ── Model validator: normalize type_designation inside individual_equipment ──
|
|
@model_validator(mode='after')
|
|
def normalize_individual_equipment(self):
|
|
"""Normalize type_designation inside individual_equipment.car_specs.
|
|
|
|
SAFE None handling: checks every level before calling .upper().
|
|
- individual_equipment may be None or empty dict
|
|
- car_specs may be None or missing key
|
|
- type_designation may be None, empty string, or a valid string
|
|
"""
|
|
if not self.individual_equipment:
|
|
return self
|
|
if not isinstance(self.individual_equipment, dict):
|
|
return self
|
|
|
|
car_specs = self.individual_equipment.get('car_specs')
|
|
if not car_specs or not isinstance(car_specs, dict):
|
|
return self
|
|
|
|
td = car_specs.get('type_designation')
|
|
if td is not None and isinstance(td, str) and td.strip():
|
|
car_specs['type_designation'] = td.strip().upper().replace(' ', '')
|
|
return self
|
|
|
|
# ── Root validator: ensure at least one of vin or license_plate is provided ──
|
|
@root_validator(skip_on_failure=True)
|
|
def validate_vin_or_plate(cls, values):
|
|
"""Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám."""
|
|
license_plate = values.get('license_plate')
|
|
vin = values.get('vin')
|
|
if not license_plate and not vin:
|
|
raise ValueError('Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám.')
|
|
return values
|
|
|
|
# === CLASSIFICATION (Optional, but affects status) ===
|
|
brand: Optional[str] = Field(None, max_length=100, description="Márka (ha nincs catalog_id)")
|
|
model: Optional[str] = Field(None, max_length=100, description="Modell (ha nincs catalog_id)")
|
|
vehicle_class: Optional[str] = Field(None, max_length=50, description="Járműosztály")
|
|
fuel_type: Optional[str] = Field(None, max_length=50, description="Üzemanyag típus")
|
|
|
|
# === TECHNICAL SPECS (Optional) ===
|
|
catalog_id: Optional[int] = Field(None, description="Opcionális katalógus ID (ha ismert a modell)")
|
|
engine_capacity: Optional[int] = Field(None, ge=0, description="Hengerűrtartalom (cm³)")
|
|
power_kw: Optional[int] = Field(None, ge=0, description="Teljesítmény (kW)")
|
|
torque_nm: Optional[int] = Field(None, ge=0, description="Nyomaték (Nm)")
|
|
cylinder_layout: Optional[str] = Field(None, max_length=50, description="Hengerelrendezés")
|
|
transmission_type: Optional[str] = Field(None, max_length=50, description="Váltó típus")
|
|
drive_type: Optional[str] = Field(None, max_length=50, description="Hajtás")
|
|
euro_classification: Optional[str] = Field(None, max_length=10, description="EURO besorolás")
|
|
|
|
# === PHYSICAL DIMENSIONS (Optional) ===
|
|
curb_weight: Optional[int] = Field(None, ge=0, description="Saját tömeg (kg)")
|
|
max_weight: Optional[int] = Field(None, ge=0, description="Össztömeg (kg)")
|
|
cargo_volume_x: Optional[float] = Field(None, ge=0, description="Csomagtartó hossz (cm)")
|
|
cargo_volume_y: Optional[float] = Field(None, ge=0, description="Csomagtartó szélesség (cm)")
|
|
door_count: Optional[int] = Field(None, ge=0, description="Ajtók száma")
|
|
seat_count: Optional[int] = Field(None, ge=0, description="Ülések száma")
|
|
|
|
# === EQUIPMENT (Optional) ===
|
|
trim_level: Optional[str] = Field(None, max_length=100, description="Felszereltségi szint")
|
|
roof_type: Optional[str] = Field(None, max_length=50, description="Tető típus")
|
|
audio_system_type: Optional[str] = Field(None, max_length=100, description="Hangrendszer")
|
|
individual_equipment: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Egyedi felszerelések")
|
|
|
|
# === MILEAGE (Optional) ===
|
|
current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás")
|
|
|
|
# === TIMELINE (Optional) ===
|
|
year_of_manufacture: Optional[int] = Field(None, ge=1900, le=2100, description="Gyártási év")
|
|
first_registration_date: Optional[datetime] = Field(None, description="Első forgalomba helyezés dátuma")
|
|
|
|
# === ORGANIZATION (Optional) ===
|
|
organization_id: Optional[int] = Field(None, description="Szervezet ID (alapértelmezett a felhasználó szervezete)")
|
|
branch_id: Optional[UUID] = Field(None, description="Garázs (Branch) ID, ahova a járművet rendeljük. Ha nincs megadva, a szervezet központi garázsába kerül.")
|
|
|
|
# === WARRANTY ===
|
|
is_under_warranty: bool = Field(default=False, description="Érvényes jótállás")
|
|
warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma")
|
|
|
|
# === REGISTRATION DOCUMENTS ===
|
|
registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma")
|
|
registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje")
|
|
vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma")
|
|
|
|
# === INTERNATIONALIZATION (ÚJ) ===
|
|
registration_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')")
|
|
first_domestic_registration_date: Optional[date] = Field(None, description="Első belföldi forgalomba helyezés dátuma")
|
|
import_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Importország (ISO 3166-1 alpha-2), ha importált jármű")
|
|
title_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv szám (title document)")
|
|
engine_number: Optional[str] = Field(None, max_length=100, description="Motorszám")
|
|
number_of_previous_owners: Optional[int] = Field(None, ge=0, description="Előző tulajdonosok száma")
|
|
|
|
# === PRIMARY VEHICLE FLAG ===
|
|
is_primary: bool = Field(default=False, description="Elsődleges jármű (tárolva: individual_equipment JSONB)")
|
|
|
|
# === DATA STATUS (for transfer/duplicate scenarios) ===
|
|
data_status: Optional[str] = Field(None, description="Adat státusz (pl. 'draft' tulajdonosváltáskor)")
|
|
|
|
# ── Internationalization validators ──
|
|
@field_validator('registration_country', 'import_country')
|
|
@classmethod
|
|
def validate_iso_country(cls, v: Optional[str]) -> Optional[str]:
|
|
"""registration_country és import_country: pontosan 2 karakteres nagybetűs string (ISO 3166-1 alpha-2)."""
|
|
if v is None or not isinstance(v, str):
|
|
return v
|
|
cleaned = v.strip().upper()
|
|
if len(cleaned) != 2:
|
|
raise ValueError(f'Az országkódnak pontosan 2 karakter hosszúnak kell lennie (ISO 3166-1 alpha-2), kapott: "{v}"')
|
|
return cleaned
|
|
|
|
@field_validator('number_of_previous_owners')
|
|
@classmethod
|
|
def validate_previous_owners(cls, v: Optional[int]) -> Optional[int]:
|
|
"""number_of_previous_owners >= 0."""
|
|
if v is not None and v < 0:
|
|
raise ValueError('Az előző tulajdonosok száma nem lehet negatív')
|
|
return v
|
|
|
|
@field_validator('first_domestic_registration_date')
|
|
@classmethod
|
|
def validate_not_future_date(cls, v: Optional[date]) -> Optional[date]:
|
|
"""first_domestic_registration_date nem lehet jövőbeli dátum."""
|
|
if v is not None and v > date.today():
|
|
raise ValueError('Az első belföldi forgalomba helyezés dátuma nem lehet jövőbeli')
|
|
return v
|
|
|
|
# === STATUS VALIDATION ===
|
|
@validator('status', pre=True, always=True)
|
|
def determine_status(cls, v, values):
|
|
"""Automatikus státusz meghatározás az adatkomplettség alapján."""
|
|
if v is not None:
|
|
return v
|
|
|
|
# Ellenőrizzük az 5 alapvető mezőt
|
|
required_fields = ['license_plate', 'brand', 'model', 'vehicle_class', 'fuel_type']
|
|
has_all_required = all(
|
|
values.get(field) is not None and str(values.get(field)).strip() != ''
|
|
for field in required_fields
|
|
)
|
|
|
|
return "active" if has_all_required else "draft"
|
|
|
|
# === COMPUTED FIELD: status ===
|
|
status: Optional[str] = Field(None, description="Automatikusan számított státusz (draft/active)")
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class AssetUpdate(BaseModel):
|
|
""" Jármű adatainak frissítése - minden mező opcionális. """
|
|
# === CORE IDENTIFICATION ===
|
|
license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
|
|
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám")
|
|
|
|
# ── Pre-validator: convert empty strings to None for optional string fields ──
|
|
@validator('license_plate', 'vin', 'trim_level',
|
|
'cylinder_layout', 'transmission_type', 'drive_type',
|
|
'euro_classification', 'roof_type', 'audio_system_type',
|
|
pre=True, always=True)
|
|
def empty_str_to_none(cls, v):
|
|
"""Convert empty strings to None so Pydantic doesn't reject them with min_length."""
|
|
if v is not None and isinstance(v, str) and v.strip() == '':
|
|
return None
|
|
return v
|
|
|
|
# ── Data normalization validators ──
|
|
@field_validator('brand')
|
|
@classmethod
|
|
def normalize_brand(cls, v: Optional[str]) -> Optional[str]:
|
|
"""Brand: UPPERCASE, strip whitespace."""
|
|
if v is None or not isinstance(v, str):
|
|
return v
|
|
return v.strip().upper()
|
|
|
|
@field_validator('model')
|
|
@classmethod
|
|
def normalize_model(cls, v: Optional[str]) -> Optional[str]:
|
|
"""Model: UPPERCASE, remove ALL spaces."""
|
|
if v is None or not isinstance(v, str):
|
|
return v
|
|
return v.upper().replace(' ', '')
|
|
|
|
# ── Model validator: normalize type_designation inside individual_equipment ──
|
|
@model_validator(mode='after')
|
|
def normalize_individual_equipment(self):
|
|
"""Normalize type_designation inside individual_equipment.car_specs.
|
|
|
|
SAFE None handling: checks every level before calling .upper().
|
|
- individual_equipment may be None or empty dict
|
|
- car_specs may be None or missing key
|
|
- type_designation may be None, empty string, or a valid string
|
|
"""
|
|
if not self.individual_equipment:
|
|
return self
|
|
if not isinstance(self.individual_equipment, dict):
|
|
return self
|
|
|
|
car_specs = self.individual_equipment.get('car_specs')
|
|
if not car_specs or not isinstance(car_specs, dict):
|
|
return self
|
|
|
|
td = car_specs.get('type_designation')
|
|
if td is not None and isinstance(td, str) and td.strip():
|
|
car_specs['type_designation'] = td.strip().upper().replace(' ', '')
|
|
return self
|
|
|
|
# ── Model validator: ensure at least one of vin or license_plate is provided ──
|
|
@model_validator(mode='after')
|
|
def validate_vin_or_plate(self):
|
|
"""Update esetén: ha mindkét mezőt None-ra állítják explicit, az nem engedélyezett.
|
|
|
|
Működés:
|
|
- Ha egyik mező sincs a payload-ban (üres update) → engedélyezve (nincs változás)
|
|
- Ha mindkettő None explicit → hiba (nem lehet mindkettőt elvenni)
|
|
- Ha legalább az egyik meg van adva → OK
|
|
"""
|
|
# model_fields_set tartalmazza az explicit megadott mezőket
|
|
if 'license_plate' not in self.model_fields_set and 'vin' not in self.model_fields_set:
|
|
return self
|
|
|
|
if not self.license_plate and not self.vin:
|
|
raise ValueError('Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám.')
|
|
return self
|
|
|
|
# === CLASSIFICATION ===
|
|
brand: Optional[str] = Field(None, max_length=100, description="Márka")
|
|
model: Optional[str] = Field(None, max_length=100, description="Modell")
|
|
vehicle_class: Optional[str] = Field(None, max_length=50, description="Járműosztály")
|
|
fuel_type: Optional[str] = Field(None, max_length=50, description="Üzemanyag típus")
|
|
|
|
# === TECHNICAL SPECS ===
|
|
catalog_id: Optional[int] = Field(None, description="Katalógus ID")
|
|
engine_capacity: Optional[int] = Field(None, ge=0, description="Hengerűrtartalom (cm³)")
|
|
power_kw: Optional[int] = Field(None, ge=0, description="Teljesítmény (kW)")
|
|
torque_nm: Optional[int] = Field(None, ge=0, description="Nyomaték (Nm)")
|
|
cylinder_layout: Optional[str] = Field(None, max_length=50, description="Hengerelrendezés")
|
|
transmission_type: Optional[str] = Field(None, max_length=50, description="Váltó típus")
|
|
drive_type: Optional[str] = Field(None, max_length=50, description="Hajtás")
|
|
euro_classification: Optional[str] = Field(None, max_length=10, description="EURO besorolás")
|
|
|
|
# === PHYSICAL DIMENSIONS ===
|
|
curb_weight: Optional[int] = Field(None, ge=0, description="Saját tömeg (kg)")
|
|
max_weight: Optional[int] = Field(None, ge=0, description="Össztömeg (kg)")
|
|
cargo_volume_x: Optional[float] = Field(None, ge=0, description="Csomagtartó hossz (cm)")
|
|
cargo_volume_y: Optional[float] = Field(None, ge=0, description="Csomagtartó szélesség (cm)")
|
|
door_count: Optional[int] = Field(None, ge=0, description="Ajtók száma")
|
|
seat_count: Optional[int] = Field(None, ge=0, description="Ülések száma")
|
|
|
|
# === EQUIPMENT ===
|
|
trim_level: Optional[str] = Field(None, max_length=100, description="Felszereltségi szint")
|
|
roof_type: Optional[str] = Field(None, max_length=50, description="Tető típus")
|
|
audio_system_type: Optional[str] = Field(None, max_length=100, description="Hangrendszer")
|
|
individual_equipment: Optional[Dict[str, Any]] = Field(None, description="Egyedi felszerelések")
|
|
|
|
# === TIMELINE ===
|
|
year_of_manufacture: Optional[int] = Field(None, ge=1900, le=2100, description="Gyártási év")
|
|
first_registration_date: Optional[datetime] = Field(None, description="Első forgalomba helyezés dátuma")
|
|
|
|
# === WARRANTY ===
|
|
is_under_warranty: Optional[bool] = Field(None, description="Érvényes jótállás")
|
|
warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma")
|
|
|
|
# === REGISTRATION DOCUMENTS ===
|
|
registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma")
|
|
registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje")
|
|
vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma")
|
|
|
|
# === INTERNATIONALIZATION (ÚJ) ===
|
|
registration_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')")
|
|
first_domestic_registration_date: Optional[date] = Field(None, description="Első belföldi forgalomba helyezés dátuma")
|
|
import_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Importország (ISO 3166-1 alpha-2), ha importált jármű")
|
|
title_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv szám (title document)")
|
|
engine_number: Optional[str] = Field(None, max_length=100, description="Motorszám")
|
|
number_of_previous_owners: Optional[int] = Field(None, ge=0, description="Előző tulajdonosok száma")
|
|
|
|
# ── Internationalization validators ──
|
|
@field_validator('registration_country', 'import_country')
|
|
@classmethod
|
|
def validate_iso_country(cls, v: Optional[str]) -> Optional[str]:
|
|
"""registration_country és import_country: pontosan 2 karakteres nagybetűs string (ISO 3166-1 alpha-2)."""
|
|
if v is None or not isinstance(v, str):
|
|
return v
|
|
cleaned = v.strip().upper()
|
|
if len(cleaned) != 2:
|
|
raise ValueError(f'Az országkódnak pontosan 2 karakter hosszúnak kell lennie (ISO 3166-1 alpha-2), kapott: "{v}"')
|
|
return cleaned
|
|
|
|
@field_validator('number_of_previous_owners')
|
|
@classmethod
|
|
def validate_previous_owners(cls, v: Optional[int]) -> Optional[int]:
|
|
"""number_of_previous_owners >= 0."""
|
|
if v is not None and v < 0:
|
|
raise ValueError('Az előző tulajdonosok száma nem lehet negatív')
|
|
return v
|
|
|
|
@field_validator('first_domestic_registration_date')
|
|
@classmethod
|
|
def validate_not_future_date(cls, v: Optional[date]) -> Optional[date]:
|
|
"""first_domestic_registration_date nem lehet jövőbeli dátum."""
|
|
if v is not None and v > date.today():
|
|
raise ValueError('Az első belföldi forgalomba helyezés dátuma nem lehet jövőbeli')
|
|
return v
|
|
|
|
# === STATUS ===
|
|
current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás")
|
|
is_primary: Optional[bool] = Field(None, description="Elsődleges jármű")
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class AssetEventCreate(BaseModel):
|
|
"""Schema for creating a new AssetEvent (Service Book entry)."""
|
|
event_type: str = Field(..., description="Event type: SERVICE, REPAIR, ACCIDENT, INSPECTION, TIRE_CHANGE, MAINTENANCE, UPGRADE, RECALL")
|
|
event_date: Optional[datetime] = Field(None, description="Event date (default: now)")
|
|
odometer_reading: Optional[int] = Field(None, ge=0, description="Odometer reading at event time")
|
|
description: Optional[str] = Field(None, max_length=2000, description="Event description")
|
|
cost_id: Optional[UUID] = Field(None, description="Associated cost record ID")
|
|
cost_amount: Optional[float] = Field(None, gt=0, description="Cost amount in the given currency. If > 0, an AssetCost record is auto-created.")
|
|
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Currency code (e.g. HUF, EUR). Defaults to HUF on backend.")
|
|
status: Optional[str] = Field(None, description="Event status (DRAFT, MISSING_TECH_DATA, COMPLETED) - auto-set by backend")
|
|
linked_expense_id: Optional[UUID] = Field(None, description="Associated AssetCost ID (bidirectional link)")
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class AssetEventResponse(BaseModel):
|
|
"""Schema for returning an AssetEvent."""
|
|
id: UUID
|
|
asset_id: UUID
|
|
user_id: Optional[int] = None
|
|
organization_id: Optional[int] = None
|
|
event_type: str
|
|
odometer_reading: Optional[int] = None
|
|
description: Optional[str] = None
|
|
cost_id: Optional[UUID] = None
|
|
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
|
|
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
|
|
status: str = "DRAFT"
|
|
linked_expense_id: Optional[UUID] = None
|
|
event_date: datetime
|
|
created_at: datetime
|
|
updated_at: Optional[datetime] = None
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
@classmethod
|
|
def model_validate(cls, obj, **kwargs):
|
|
"""Override to resolve cost_amount and currency from the related AssetCost.
|
|
|
|
GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
|
|
"""
|
|
data = {}
|
|
if hasattr(obj, '__dict__'):
|
|
# Copy ORM attributes
|
|
for field in cls.model_fields:
|
|
if hasattr(obj, field):
|
|
data[field] = getattr(obj, field)
|
|
|
|
# Resolve cost_amount and currency from the cost relationship
|
|
cost = getattr(obj, 'cost', None)
|
|
if cost is not None:
|
|
if data.get('cost_amount') is None:
|
|
# GROSS-FIRST: prefer amount_gross as the source of truth
|
|
data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
|
|
float(cost.amount_net) if cost.amount_net is not None else None
|
|
)
|
|
if data.get('currency') is None:
|
|
data['currency'] = cost.currency
|
|
return cls(**data) |