Járműkezelés majdnem kész frontend

This commit is contained in:
Roo
2026-06-11 11:22:47 +00:00
parent 90e3173fbc
commit 0a3fd8de74
18 changed files with 2749 additions and 99 deletions

View File

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

View File

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

View File

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

View File

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