# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py import uuid import logging from datetime import datetime, timezone from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, text, or_ from sqlalchemy.orm import selectinload from app.db.session import get_db from app.api.deps import get_current_user from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember 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, AssetUpdate, AssetEventCreate, AssetEventResponse logger = logging.getLogger(__name__) class ArchiveVehicleRequest(BaseModel): """Payload a jármű archiválásához (Soft Delete).""" final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás") archive_reason: str = Field(..., min_length=1, max_length=100, description="Eltávolítás oka") router = APIRouter() @router.get("/vehicles/quota-status") async def get_vehicle_quota_status( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Preemptive quota check endpoint for the frontend. Returns whether the user can add a new vehicle BEFORE they start the wizard. GET /api/v1/assets/vehicles/quota-status Returns: { "can_add": bool, "current_count": int, "limit": int } """ try: # Determine organization ID based on user's active scope (garage isolation) org_id = None if current_user.scope_id is not None: try: org_id = int(current_user.scope_id) except (ValueError, TypeError): pass # Get the user's vehicle limit allowed_limit = await AssetService.get_user_vehicle_limit( db, current_user.id, org_id ) # SAFETY: Ensure limit is at least 1 allowed_limit = max(allowed_limit or 1, 1) # Count only active vehicles (draft vehicles don't count toward the limit) from sqlalchemy import func if org_id is not None: count_stmt = select(func.count(Asset.id)).where( Asset.current_organization_id == org_id, Asset.owner_person_id == current_user.person_id, Asset.status == "active" ) else: count_stmt = select(func.count(Asset.id)).where( Asset.current_organization_id.is_(None), Asset.owner_person_id == current_user.person_id, Asset.status == "active" ) current_count = (await db.execute(count_stmt)).scalar() or 0 can_add = current_count < allowed_limit logger.info( f"Quota check for user {current_user.id} (org={org_id}): " f"current_count={current_count}, allowed_limit={allowed_limit}, can_add={can_add}" ) return { "can_add": can_add, "current_count": current_count, "limit": allowed_limit } except Exception as e: logger.error(f"Quota status check error for user {current_user.id}: {e}") # Fail open — if we can't check quota, allow the user to proceed return {"can_add": True, "current_count": 0, "limit": 9999} @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, limit: int = 100, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Get all vehicles/assets belonging to the current user or their organization. This endpoint returns a paginated list of vehicles that the authenticated user has access to (either as owner or through organization membership). 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 = ( select(Asset) .where( or_( Asset.owner_org_id.is_(None), Asset.operator_org_id.is_(None) ), or_( Asset.owner_person_id == current_user.person_id, Asset.operator_person_id == current_user.person_id ) ) .order_by(order_expr, Asset.created_at.desc()) .offset(skip) .limit(limit) .options( selectinload(Asset.catalog), selectinload(Asset.odometer_readings) ) ) else: # Corporate mode: only show vehicles belonging to the active organization's garages try: scope_org_id = int(current_user.scope_id) except (ValueError, TypeError): # If scope_id is not a valid integer, treat as no organization scope_org_id = None if scope_org_id is None: # Fallback: no valid organization, return empty list return [] # First, get all branch IDs (garages) for this organization from app.models.marketplace.organization import Branch branch_stmt = select(Branch.id).where( Branch.organization_id == scope_org_id, Branch.is_deleted == False, Branch.status == "active" ) branch_result = await db.execute(branch_stmt) branch_ids = [row[0] for row in branch_result.all()] if not branch_ids: # Organization has no active garages, return empty list return [] # Query assets that are in any of the organization's garages stmt = ( select(Asset) .where( Asset.branch_id.in_(branch_ids), Asset.status == "active" ) .order_by(order_expr, Asset.created_at.desc()) .offset(skip) .limit(limit) .options( selectinload(Asset.catalog), selectinload(Asset.odometer_readings) ) ) result = await db.execute(stmt) assets = result.scalars().all() return assets @router.get("/{asset_id}/financial-summary", response_model=Dict[str, Any]) async def get_asset_financial_report( asset_id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ MB 2.0 Dinamikus Pénzügyi Riport. Visszaadja a kategóriákra bontott és az összesített költségeket (Local/EUR). """ # 1. Jogosultság ellenőrzése (Csak a tulajdonos vagy admin láthatja) # (Itt egy gyors check, hogy az asset az övé-e) try: return await cost_service.get_asset_financial_summary(db, asset_id) except Exception as e: raise HTTPException(status_code=500, detail="Hiba a riport generálásakor") @router.get("/{asset_id}/costs", response_model=List[AssetCostResponse]) async def list_asset_costs( asset_id: uuid.UUID, skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Tételes költséglista lapozással (Pagination).""" stmt = ( select(AssetCost) .options(selectinload(AssetCost.category)) .where(AssetCost.asset_id == asset_id) .order_by(desc(AssetCost.date)) .offset(skip) .limit(limit) ) res = await db.execute(stmt) costs = res.scalars().all() # Map DB models to response, enriching with category info and data fields result = [] for cost in costs: cost_dict = { "id": cost.id, "asset_id": cost.asset_id, "organization_id": cost.organization_id, "amount_net": cost.amount_net, "currency": cost.currency, "date": cost.date, "invoice_number": cost.invoice_number, "data": cost.data or {}, "category_id": cost.category_id, "category_name": cost.category.name if cost.category else None, "category_code": cost.category.code if cost.category else None, "description": (cost.data or {}).get("description"), "mileage_at_cost": (cost.data or {}).get("mileage_at_cost"), } result.append(AssetCostResponse(**cost_dict)) return result @router.get("/{asset_id}", response_model=AssetResponse) async def get_asset( asset_id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Get detailed information about a specific vehicle/asset. Returns the asset's full technical profile including catalog data and vehicle model definition specifications. """ # Check if user has access to this asset # 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()] # Query asset with catalog and master definition stmt = ( select(Asset) .where( Asset.id == asset_id, or_( Asset.owner_person_id == current_user.id, Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False, Asset.operator_person_id == current_user.id, Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False ) ) .options( selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition), selectinload(Asset.odometer_readings) ) ) 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 access it" ) return asset @router.post("/vehicles", response_model=AssetResponse, status_code=status.HTTP_201_CREATED) async def create_or_claim_vehicle( payload: AssetCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Új jármű hozzáadása vagy meglévő jármű igénylése a flottához. A végpont a következőket végzi: - Ellenőrzi a felhasználó járműlimitjét - 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." ) # ── 🛡️ KETTŐS VÉDELEM: Szervezet hozzárendelés ── # 1. PRIORITÁS: A frontend által küldött organization_id (ha van) # 2. FALLBACK: A felhasználó aktív scope-ja (scope_id) # 3. VASÁJTÓ: Ha egyik sincs, lekérdezzük a fleet.organization_members táblából org_id = payload.organization_id if org_id is None and current_user.scope_id is not None: try: org_id = int(current_user.scope_id) except (ValueError, TypeError): pass # VASÁJTÓ FALLBACK: Ha még mindig nincs org_id, keressük meg a felhasználó # első elérhető szervezetét a fleet.organization_members táblában if org_id is None: from app.models.marketplace.organization import OrganizationMember member_stmt = ( select(OrganizationMember.organization_id) .where(OrganizationMember.user_id == current_user.id) .limit(1) ) member_result = await db.execute(member_stmt) member_row = member_result.first() if member_row: org_id = member_row[0] logger.info( f"Iron Door fallback: assigned org_id={org_id} from " f"organization_members for user {current_user.id}" ) else: logger.warning( f"Iron Door fallback: user {current_user.id} has no " f"organization memberships — asset will be created without org" ) asset = await AssetService.create_or_claim_vehicle( db=db, user_id=current_user.id, org_id=org_id, asset_data=payload ) return asset except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: raise except Exception as e: logger.error(f"Vehicle creation failed: {e}", exc_info=True) 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.error(f"Vehicle update error: {e}", exc_info=True) 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, skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ List maintenance records for a specific vehicle. Returns paginated list of maintenance costs with cost_category = 'maintenance'. """ # Check if user has access to this asset 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()] # Check asset access asset_stmt = select(Asset).where( Asset.id == asset_id, or_( Asset.owner_person_id == current_user.id, Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False, Asset.operator_person_id == current_user.id, Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False ) ) asset_result = await db.execute(asset_stmt) asset = 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 access it" ) # Query maintenance costs stmt = ( select(AssetCost) .where( AssetCost.asset_id == asset_id, AssetCost.cost_category == "maintenance" ) .order_by(desc(AssetCost.date)) .offset(skip) .limit(limit) ) res = await db.execute(stmt) return res.scalars().all() @router.post("/{asset_id}/maintenance", response_model=AssetCostResponse, status_code=status.HTTP_201_CREATED) async def create_maintenance_record( asset_id: uuid.UUID, payload: Dict[str, Any], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Add a maintenance record for a vehicle. Expected payload fields: - date: ISO datetime string (required) - odometer: integer (optional, current mileage) - description: string (required) - cost: float (required, net amount) - currency: string (optional, default: "EUR") - invoice_number: string (optional) """ # Get user's organization memberships (imports already at module level) 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()] # Check asset access and get asset asset_stmt = select(Asset).where( Asset.id == asset_id, or_( Asset.owner_person_id == current_user.id, Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False, Asset.operator_person_id == current_user.id, Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False ) ) asset_result = await db.execute(asset_stmt) asset = 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 access it" ) # Validate required fields required_fields = ["date", "description", "cost"] for field in required_fields: if field not in payload: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Missing required field: {field}" ) try: # Parse date date = datetime.fromisoformat(payload["date"].replace("Z", "+00:00")) # Determine organization ID: use asset's current org, owner org, or user's active organization organization_id = asset.current_organization_id or asset.owner_org_id if not organization_id: # Get user's active organization from their scope_id if current_user.scope_id: try: organization_id = int(current_user.scope_id) except (ValueError, TypeError): # If scope_id is not a valid integer, try to get from organization memberships stmt = select(OrganizationMember.organization_id).where( OrganizationMember.user_id == current_user.id ).limit(1) result = await db.execute(stmt) org_row = result.first() organization_id = org_row[0] if org_row else None else: # Try to get from organization memberships stmt = select(OrganizationMember.organization_id).where( OrganizationMember.user_id == current_user.id ).limit(1) result = await db.execute(stmt) org_row = result.first() organization_id = org_row[0] if org_row else None if not organization_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot determine organization for this cost record. Please ensure you have an active organization." ) # Create AssetCost record with MAINTENANCE category (id=2) maintenance_cost = AssetCost( asset_id=asset_id, organization_id=organization_id, category_id=2, amount_net=float(payload["cost"]), currency=payload.get("currency", "EUR"), date=date, invoice_number=payload.get("invoice_number"), data={ "odometer": payload.get("odometer"), "description": payload["description"], "type": "maintenance" } ) db.add(maintenance_cost) await db.flush() # Flush to get maintenance_cost.id # Also create an AssetEvent for the maintenance (linked via cost_id) maintenance_event = AssetEvent( asset_id=asset_id, user_id=current_user.id, organization_id=organization_id, event_type="MAINTENANCE", odometer_reading=payload.get("odometer"), description=payload.get("description", "Maintenance record"), cost_id=maintenance_cost.id, event_date=date, ) db.add(maintenance_event) # Single commit for both records await db.commit() await db.refresh(maintenance_cost) return maintenance_cost except ValueError as e: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Invalid data format: {str(e)}" ) except Exception as e: await db.rollback() logger.error(f"Maintenance record creation error: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error while creating maintenance record" ) # ═══════════════════════════════════════════════════════════════════════════════ # DIGITÁLIS SZERVIZKÖNYV (Service Book) — AssetEvent végpontok # ═══════════════════════════════════════════════════════════════════════════════ async def _check_asset_access( db: AsyncSession, asset_id: uuid.UUID, current_user: User ) -> Asset: """ Helper: check if the current user has access to the given asset. Returns the Asset or raises 404. """ from app.models.marketplace.organization import OrganizationMember 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()] stmt = ( select(Asset) .where( Asset.id == asset_id, or_( Asset.owner_person_id == current_user.id, Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False, Asset.operator_person_id == current_user.id, Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False ) ) ) 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 access it" ) return asset async def _resolve_cost_category_for_event(db: AsyncSession, event_type: str) -> int: """ Helper: resolve a CostCategory ID based on the AssetEvent event_type. Maps service-book event types to cost categories: - SERVICE, MAINTENANCE, INSPECTION, TIRE_CHANGE -> 'MAINTENANCE' (karbantartás) - REPAIR, ACCIDENT -> 'REPAIR' (javítás) - UPGRADE, RECALL -> 'UPGRADE' (fejlesztés) Falls back to a generic 'MAINTENANCE' category if none found. """ category_map = { "SERVICE": "MAINTENANCE", "MAINTENANCE": "MAINTENANCE", "INSPECTION": "MAINTENANCE", "TIRE_CHANGE": "MAINTENANCE", "REPAIR": "REPAIR", "ACCIDENT": "REPAIR", "UPGRADE": "UPGRADE", "RECALL": "UPGRADE", } category_code = category_map.get(event_type, "MAINTENANCE") stmt = select(CostCategory.id).where( CostCategory.code == category_code ).limit(1) result = await db.execute(stmt) cat_id = result.scalar_one_or_none() if cat_id is None: # Fallback: try to find any category with 'MAINTENANCE' in name fallback_stmt = select(CostCategory.id).where( CostCategory.code.ilike("%MAINTENANCE%") ).limit(1) fallback_result = await db.execute(fallback_stmt) cat_id = fallback_result.scalar_one_or_none() if cat_id is None: # Ultimate fallback: pick the first available cost category any_stmt = select(CostCategory.id).limit(1) any_result = await db.execute(any_stmt) cat_id = any_result.scalar_one_or_none() if cat_id is None: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="No cost category found in the system. Please seed cost categories first." ) return cat_id @router.get("/{asset_id}/events", response_model=List[AssetEventResponse]) async def list_asset_events( asset_id: uuid.UUID, skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Szervizkönyv események listázása. GET /api/v1/assets/{asset_id}/events Visszaadja a járműhöz tartozó összes AssetEvent rekordot, dátum szerint csökkenő sorrendben (legújabb elöl). """ # Jogosultság ellenőrzés await _check_asset_access(db, asset_id, current_user) stmt = ( select(AssetEvent) .options(selectinload(AssetEvent.cost)) .where(AssetEvent.asset_id == asset_id) .order_by(desc(AssetEvent.event_date)) .offset(skip) .limit(limit) ) result = await db.execute(stmt) events = result.scalars().all() return events @router.post("/{asset_id}/events", response_model=AssetEventResponse, status_code=status.HTTP_201_CREATED) async def create_asset_event( asset_id: uuid.UUID, payload: AssetEventCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Új szervizkönyv esemény rögzítése. POST /api/v1/assets/{asset_id}/events **Üzleti logika:** - Ha a beküldött `odometer_reading` nagyobb, mint a jármű jelenlegi `current_mileage` értéke, akkor: 1. Létrehoz egy új OdometerReading rekordot 2. Frissíti a jármű `current_mileage` mezőjét - Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE), és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül. """ # Jogosultság ellenőrzés asset = await _check_asset_access(db, asset_id, current_user) # Dátum: ha nincs megadva, használjuk a jelenlegi időt event_date = payload.event_date or datetime.now(timezone.utc) # Szervezet meghatározása organization_id = asset.current_organization_id or asset.owner_org_id try: # ── Üzleti logika: AssetCost automatikus létrehozása ── cost_id = payload.cost_id if payload.cost_amount is not None and payload.cost_amount > 0: # Költségkategória feloldása az esemény típusa alapján # SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás cost_category_id = await _resolve_cost_category_for_event(db, payload.event_type) currency = payload.currency or "HUF" cost_record = AssetCost( asset_id=asset_id, organization_id=organization_id, category_id=cost_category_id, amount_net=payload.cost_amount, currency=currency, date=event_date, data={ "description": payload.description or f"Service event: {payload.event_type}", "mileage_at_cost": payload.odometer_reading, "source": "service_book", "event_type": payload.event_type, }, ) db.add(cost_record) await db.flush() # Flush to get the cost_record.id cost_id = cost_record.id logger.info( f"AssetCost auto-created for asset {asset_id}: " f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})" ) # Új esemény létrehozása event = AssetEvent( asset_id=asset_id, user_id=current_user.id, organization_id=organization_id, event_type=payload.event_type, odometer_reading=payload.odometer_reading, description=payload.description, cost_id=cost_id, event_date=event_date, ) db.add(event) # ── Üzleti logika: Km óra állás frissítése ── if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage: # 1. Új OdometerReading rekord létrehozása odometer = OdometerReading( asset_id=asset_id, reading=payload.odometer_reading, source="manual", ) db.add(odometer) # 2. Jármű current_mileage frissítése asset.current_mileage = payload.odometer_reading asset.updated_at = datetime.now(timezone.utc) logger.info( f"Odometer updated for asset {asset_id}: " f"{asset.current_mileage} → {payload.odometer_reading} " f"(via event {payload.event_type})" ) await db.commit() await db.refresh(event, attribute_names=["cost"]) return event except Exception as e: await db.rollback() logger.error(f"Asset event creation error for {asset_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Hiba a szervizkönyv esemény létrehozásakor" ) @router.post("/vehicles/{vehicle_id}/archive", response_model=AssetResponse) async def archive_vehicle( vehicle_id: uuid.UUID, payload: ArchiveVehicleRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ Jármű biztonságos kivezetése (Strict Soft Delete). POST /api/v1/assets/vehicles/{vehicle_id}/archive A végpont: 1. Frissíti a jármű current_mileage értékét a megadott final_mileage-ra 2. Átállítja a státuszt 'archived'-ra 3. Nullázza a tulajdonosi mezőket (owner_person_id, owner_org_id) 4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be 5. Naplózza a biztonsági auditba Payload: - final_mileage: int (utolsó km óra állás) - archive_reason: string (Eladás, Gazdasági totálkár / Bontás, Lízing/Bérlet lejárta, Téves rögzítés, Egyéb) """ try: asset = await AssetService.archive_vehicle( db=db, asset_id=vehicle_id, user_id=current_user.id, final_mileage=payload.final_mileage, archive_reason=payload.archive_reason, ) return asset except HTTPException: raise except Exception as e: logger.error(f"Vehicle archive error for {vehicle_id}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Belső szerverhiba a jármű archiválásakor" )