Járműkezelés majdnem kész frontend
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy import select, desc, text, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.session import get_db
|
||||
@@ -14,10 +15,47 @@ from app.models.identity import User
|
||||
from app.services.cost_service import cost_service
|
||||
from app.services.asset_service import AssetService
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
|
||||
from app.schemas.asset import AssetResponse, AssetCreate
|
||||
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/vehicles/check")
|
||||
async def check_vehicle_exists(
|
||||
license_plate: str,
|
||||
vin: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Gyors, dedikált ellenőrző végpont a frontend számára.
|
||||
Ellenőrzi, hogy a megadott rendszám VAGY alvázszám (VIN) már létezik-e
|
||||
az adatbázisban, és visszaadja a tulajdonos ID-ját, ha létezik.
|
||||
|
||||
GET /api/v1/assets/vehicles/check?license_plate=ABC123&vin=WBA12345678901234
|
||||
"""
|
||||
normalized_plate = license_plate.strip().upper()
|
||||
|
||||
conditions = [Asset.license_plate == normalized_plate]
|
||||
if vin:
|
||||
normalized_vin = vin.strip().upper()
|
||||
conditions.append(Asset.vin == normalized_vin)
|
||||
|
||||
stmt = select(Asset).where(or_(*conditions))
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
return {
|
||||
"exists": True,
|
||||
"owner_id": existing.owner_person_id,
|
||||
"owner_org_id": existing.owner_org_id
|
||||
}
|
||||
|
||||
return {"exists": False, "owner_id": None, "owner_org_id": None}
|
||||
|
||||
@router.get("/vehicles", response_model=List[AssetResponse])
|
||||
async def get_user_vehicles(
|
||||
skip: int = 0,
|
||||
@@ -33,9 +71,21 @@ async def get_user_vehicles(
|
||||
|
||||
Garage-centric logic: In corporate mode, only returns vehicles physically
|
||||
parked in the active organization's garages (branches).
|
||||
|
||||
Sorting: is_primary vehicles appear first (based on individual_equipment JSONB),
|
||||
then ordered by created_at descending.
|
||||
"""
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
# JSONB ordering: is_primary (True) first, then by created_at desc
|
||||
# individual_equipment -> 'is_primary' path, cast to text then boolean for ordering
|
||||
is_primary_expr = Asset.individual_equipment['is_primary'].astext.cast(
|
||||
# Use SQL cast to boolean via text expression
|
||||
type_=None
|
||||
)
|
||||
# Use a raw SQL expression for JSONB boolean ordering
|
||||
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
|
||||
|
||||
if current_user.scope_id is None:
|
||||
# Personal mode: only show vehicles owned/operated personally (no organization)
|
||||
stmt = (
|
||||
@@ -50,7 +100,7 @@ async def get_user_vehicles(
|
||||
Asset.operator_person_id == current_user.person_id
|
||||
)
|
||||
)
|
||||
.order_by(Asset.created_at.desc())
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(selectinload(Asset.catalog))
|
||||
@@ -88,7 +138,7 @@ async def get_user_vehicles(
|
||||
Asset.branch_id.in_(branch_ids),
|
||||
Asset.status == "active"
|
||||
)
|
||||
.order_by(Asset.created_at.desc())
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(selectinload(Asset.catalog))
|
||||
@@ -200,20 +250,48 @@ async def create_or_claim_vehicle(
|
||||
|
||||
A végpont a következőket végzi:
|
||||
- Ellenőrzi a felhasználó járműlimitjét
|
||||
- Ha a VIN már létezik, tulajdonjog-átvitelt kezdeményez
|
||||
- OKOS DUPLEX VÉDELEM: license_plate alapján ellenőrzi a duplikációt
|
||||
- Szabály A: Ha a rendszám létezik ÉS a tulajdonos ugyanaz → 400 hiba
|
||||
- Szabály B: Ha a rendszám létezik, de más a tulajdonos → data_status='draft'
|
||||
- Ha új, létrehozza a járművet és a kapcsolódó digitális ikreket
|
||||
- XP jutalom adása a felhasználónak
|
||||
"""
|
||||
try:
|
||||
# ── OKOS DUPLEX VÉDELEM: license_plate ellenőrzés ──
|
||||
license_plate_clean = payload.license_plate.strip().upper()
|
||||
|
||||
# Check if any existing asset has this license plate
|
||||
existing_stmt = select(Asset).where(
|
||||
Asset.license_plate == license_plate_clean
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
existing_asset = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing_asset:
|
||||
# Szabály A: Saját jármű duplikációja
|
||||
if existing_asset.owner_person_id == current_user.person_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ez a jármű már szerepel a garázsodban!"
|
||||
)
|
||||
|
||||
# Szabály B: Tulajdonosváltás / Átmeneti állapot
|
||||
# A rendszám létezik, de más a tulajdonosa
|
||||
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
|
||||
payload.data_status = "draft"
|
||||
logger.info(
|
||||
f"License plate {license_plate_clean} exists with different owner "
|
||||
f"(owner_person_id={existing_asset.owner_person_id} != "
|
||||
f"current_user.person_id={current_user.person_id}). "
|
||||
f"Setting data_status='draft' for transfer scenario."
|
||||
)
|
||||
|
||||
# Determine organization ID based on user's active scope (garage isolation)
|
||||
# The owner_org_id MUST be set to the current_user.scope_id.
|
||||
# If the scope is null, it stays null (personal).
|
||||
org_id = None
|
||||
if current_user.scope_id is not None:
|
||||
try:
|
||||
org_id = int(current_user.scope_id)
|
||||
except (ValueError, TypeError):
|
||||
# If scope_id is not a valid integer, treat as personal (no organization)
|
||||
pass
|
||||
|
||||
asset = await AssetService.create_or_claim_vehicle(
|
||||
@@ -233,6 +311,88 @@ async def create_or_claim_vehicle(
|
||||
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű létrehozásakor")
|
||||
|
||||
|
||||
@router.put("/vehicles/{asset_id}", response_model=AssetResponse)
|
||||
async def update_vehicle(
|
||||
asset_id: uuid.UUID,
|
||||
payload: AssetUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Meglévő jármű adatainak frissítése.
|
||||
|
||||
Csak a megadott mezőket frissíti (partial update).
|
||||
Ellenőrzi, hogy a felhasználónak van-e jogosultsága a járműhöz.
|
||||
"""
|
||||
from sqlalchemy import or_
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
|
||||
# Get user's organization memberships
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Find the asset
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.person_id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
)
|
||||
)
|
||||
.options(selectinload(Asset.catalog))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
|
||||
if not asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found or you don't have permission to modify it"
|
||||
)
|
||||
|
||||
try:
|
||||
# Build update dict from non-None fields
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle is_primary → individual_equipment JSONB
|
||||
if 'is_primary' in update_data:
|
||||
equipment = dict(asset.individual_equipment or {})
|
||||
equipment['is_primary'] = update_data.pop('is_primary')
|
||||
update_data['individual_equipment'] = equipment
|
||||
|
||||
# Handle individual_equipment merge
|
||||
if 'individual_equipment' in update_data and update_data['individual_equipment'] is not None:
|
||||
existing_equipment = dict(asset.individual_equipment or {})
|
||||
existing_equipment.update(update_data['individual_equipment'])
|
||||
update_data['individual_equipment'] = existing_equipment
|
||||
|
||||
# Update the asset
|
||||
for field, value in update_data.items():
|
||||
if value is not None and hasattr(asset, field):
|
||||
setattr(asset, field, value)
|
||||
|
||||
asset.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
|
||||
return asset
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Vehicle update error: {e}")
|
||||
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű frissítésekor")
|
||||
|
||||
|
||||
@router.get("/{asset_id}/maintenance", response_model=List[AssetCostResponse])
|
||||
async def list_maintenance_records(
|
||||
asset_id: uuid.UUID,
|
||||
|
||||
@@ -154,6 +154,24 @@ class Asset(Base):
|
||||
"""Always False for now, as verification is not yet implemented."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_primary(self) -> bool:
|
||||
"""
|
||||
Elsődleges jármű flag.
|
||||
Tárolva: individual_equipment JSONB mezőben ('is_primary' kulcs alatt).
|
||||
Ha nincs beállítva, alapértelmezett: False.
|
||||
"""
|
||||
if self.individual_equipment and isinstance(self.individual_equipment, dict):
|
||||
return bool(self.individual_equipment.get('is_primary', False))
|
||||
return False
|
||||
|
||||
@is_primary.setter
|
||||
def is_primary(self, value: bool) -> None:
|
||||
"""Setter: az is_primary értéket az individual_equipment JSONB-be menti."""
|
||||
if self.individual_equipment is None or not isinstance(self.individual_equipment, dict):
|
||||
self.individual_equipment = {}
|
||||
self.individual_equipment['is_primary'] = bool(value)
|
||||
|
||||
@property
|
||||
def profile_completion_percentage(self) -> int:
|
||||
"""
|
||||
|
||||
@@ -104,6 +104,9 @@ class AssetResponse(BaseModel):
|
||||
# === 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)
|
||||
|
||||
|
||||
@@ -143,6 +146,9 @@ class AssetCreate(BaseModel):
|
||||
audio_system_type: Optional[str] = Field(None, max_length=100, description="Hangrendszer")
|
||||
individual_equipment: 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")
|
||||
@@ -151,6 +157,12 @@ class AssetCreate(BaseModel):
|
||||
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.")
|
||||
|
||||
# === 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)")
|
||||
|
||||
# === STATUS VALIDATION ===
|
||||
@validator('status', pre=True, always=True)
|
||||
def determine_status(cls, v, values):
|
||||
@@ -170,4 +182,51 @@ class AssetCreate(BaseModel):
|
||||
# === 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")
|
||||
|
||||
# === 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")
|
||||
|
||||
# === 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)
|
||||
@@ -145,6 +145,11 @@ class AssetService:
|
||||
print(f"DEBUG: resolved branch_id={branch_id} for target_org_id={target_org_id}")
|
||||
|
||||
|
||||
# Build individual_equipment with is_primary flag
|
||||
equipment = dict(asset_data.individual_equipment or {})
|
||||
if asset_data.is_primary:
|
||||
equipment['is_primary'] = True
|
||||
|
||||
asset_fields = {
|
||||
'vin': vin_clean,
|
||||
'license_plate': license_plate_clean,
|
||||
@@ -155,7 +160,7 @@ class AssetService:
|
||||
'owner_org_id': asset_data.organization_id or target_org_id,
|
||||
'operator_org_id': None, # AssetCreate doesn't have operator_org_id
|
||||
'status': status,
|
||||
'individual_equipment': asset_data.individual_equipment or {},
|
||||
'individual_equipment': equipment,
|
||||
'created_at': datetime.utcnow(),
|
||||
|
||||
# Classification
|
||||
@@ -182,6 +187,9 @@ class AssetService:
|
||||
'door_count': asset_data.door_count,
|
||||
'seat_count': asset_data.seat_count,
|
||||
|
||||
# Mileage
|
||||
'current_mileage': asset_data.current_mileage,
|
||||
|
||||
# Equipment
|
||||
'roof_type': asset_data.roof_type,
|
||||
'audio_system_type': asset_data.audio_system_type,
|
||||
|
||||
Reference in New Issue
Block a user