Files
service-finder/backend/app/services/asset_service.py

765 lines
37 KiB
Python
Executable File

# /opt/docker/dev/service_finder/backend/app/services/asset_service.py
from __future__ import annotations
import logging
import uuid
from typing import List, Optional, Dict, Any, TYPE_CHECKING
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, distinct
from sqlalchemy.orm import selectinload
from fastapi import HTTPException
from app.models import Asset, AssetAssignment, AssetTelemetry, AssetFinancials, VehicleModelDefinition
from app.models.identity import User
from app.models.vehicle.history import LogSeverity
from app.schemas.asset import AssetCreate
from app.services.config_service import config
from app.services.gamification_service import GamificationService
from app.services.security_service import security_service
if TYPE_CHECKING:
from .identity import User, Person
from .organization import Organization
from .vehicle_definitions import VehicleModelDefinition
logger = logging.getLogger(__name__)
class AssetService:
"""
Asset Service 2.0 - A Járművek Életciklus-menedzsere.
Kezeli a regisztrációt, a tulajdonosváltást és a flotta-korlátokat.
"""
@staticmethod
async def create_or_claim_vehicle(
db: AsyncSession,
user_id: int,
org_id: int,
asset_data: AssetCreate,
draft: bool = False
):
"""
Intelligens Jármű Rögzítés - Thick Digital Twin támogatással:
Ha új: létrehozza a teljes technikai adatokkal.
Ha már létezik: Transzfer folyamatot indít.
Automatikus státusz meghatározás az adatkomplettség alapján.
Catalog Snapshot Sync: Ha catalog_id van, betölti a hiányzó technikai adatokat.
"""
try:
# Clean input data
vin_clean = asset_data.vin.strip().upper() if asset_data.vin else None
license_plate_clean = asset_data.license_plate.strip().upper()
# Use organization_id from asset_data if provided, otherwise use the passed org_id
target_org_id = asset_data.organization_id or org_id
# 1. ADMIN LIMIT ELLENŐRZÉS (csak aktív járművek számítanak)
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one()
# Get vehicle limit using the new function that checks both user AND organization limits
# Returns the HIGHER value of user-specific and organization-specific limits
allowed_limit = await AssetService.get_user_vehicle_limit(db, user_id, target_org_id)
# Csak aktív járművek számítanak a limitbe (draft-ok nem)
# FIX: Count only the CURRENT USER's active vehicles, not ALL vehicles in the org
if target_org_id is not None:
# Organization mode: count vehicles owned by this user within the org
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == target_org_id,
Asset.owner_person_id == user.person_id,
Asset.status == "active"
)
else:
# Personal mode: count vehicles owned personally by this user
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id.is_(None),
Asset.owner_person_id == user.person_id,
Asset.status == "active"
)
current_count = (await db.execute(count_stmt)).scalar()
# Determine status based on data completeness (use Pydantic validator's logic)
# Check the 5 core fields: license_plate, brand, model, vehicle_class, fuel_type
core_fields_complete = all([
asset_data.license_plate and asset_data.license_plate.strip(),
asset_data.brand and asset_data.brand.strip(),
asset_data.model and asset_data.model.strip(),
asset_data.vehicle_class and asset_data.vehicle_class.strip(),
asset_data.fuel_type and asset_data.fuel_type.strip()
])
# Determine final status
if draft:
status = "draft"
elif not core_fields_complete:
status = "draft"
else:
status = "active"
# SAFETY: Ensure limit is at least 1 — no registered user should ever have 0 limit
allowed_limit = max(allowed_limit or 1, 1)
logger.info(
f"Limit check for user {user_id} (org={target_org_id}): "
f"current_count={current_count}, allowed_limit={allowed_limit}, status={status}"
)
if current_count >= allowed_limit and status == "active":
logger.error(f"QUOTA BLOCK TRIGGERED - User Person ID: {user.person_id}, Active Count: {current_count}, Limit: {allowed_limit}")
raise ValueError(f"Limit túllépés! A csomagod {allowed_limit} aktív autót engedélyez.")
# 2. LÉTEZIK-E MÁR A JÁRMŰ? (csak ha van VIN)
existing_asset = None
if vin_clean:
stmt = select(Asset).where(Asset.vin == vin_clean)
existing_asset = (await db.execute(stmt)).scalar_one_or_none()
if existing_asset:
# HA MÁR A JELENLEGI SZERVEZETNÉL VAN
if existing_asset.current_organization_id == target_org_id:
raise ValueError("Ez a jármű már a te garázsodban van.")
# TRANSZFER FOLYAMAT INDÍTÁSA
return await AssetService.initiate_ownership_transfer(
db, existing_asset, user_id, target_org_id, license_plate_clean or ""
)
# 3. CATALOG SNAPSHOT SYNC - Ha catalog_id van, betöltjük a hiányzó technikai adatokat
catalog_data = {}
if asset_data.catalog_id:
catalog_stmt = select(VehicleModelDefinition).where(
VehicleModelDefinition.id == asset_data.catalog_id
)
catalog = (await db.execute(catalog_stmt)).scalar_one_or_none()
if catalog:
# Map catalog fields to asset fields (only if not already provided by user)
catalog_data = {
'brand': catalog.make if not asset_data.brand else None,
'model': catalog.marketing_name if not asset_data.model else None,
'vehicle_class': catalog.vehicle_class if not asset_data.vehicle_class else None,
'fuel_type': catalog.fuel_type if not asset_data.fuel_type else None,
'power_kw': catalog.power_kw if not asset_data.power_kw else None,
'engine_capacity': catalog.engine_capacity if not asset_data.engine_capacity else None,
'euro_classification': catalog.euro_class if not asset_data.euro_classification else None,
'body_type': catalog.body_type if not asset_data.trim_level else None,
}
# Remove None values
catalog_data = {k: v for k, v in catalog_data.items() if v is not None}
# 4. ÚJ JÁRMŰ LÉTREHOZÁSA - Thick Digital Twin
# Először összeállítjuk az összes adatot (user input + catalog snapshot)
# Get default vehicle class from config if not provided
default_vehicle_class = await config.get_setting(db, "DEFAULT_VEHICLE_CLASS", default="car")
# --- BRANCH (GARÁZS) HOZZÁRENDELÉSI LOGIKA ---
branch_id = getattr(asset_data, 'branch_id', None)
if not branch_id and target_org_id:
from app.models.marketplace.organization import Branch
branch_stmt = select(Branch.id).where(Branch.organization_id == target_org_id, Branch.is_main == True)
main_branch_id = (await db.execute(branch_stmt)).scalar()
if not main_branch_id:
branch_stmt_any = select(Branch.id).where(Branch.organization_id == target_org_id).limit(1)
main_branch_id = (await db.execute(branch_stmt_any)).scalar()
branch_id = main_branch_id
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,
'catalog_id': asset_data.catalog_id,
'current_organization_id': target_org_id,
'branch_id': branch_id,
'owner_person_id': user.person_id,
'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': equipment,
'created_at': datetime.utcnow(),
# Classification
'brand': asset_data.brand or catalog_data.get('brand'),
'model': asset_data.model or catalog_data.get('model'),
'vehicle_class': asset_data.vehicle_class or catalog_data.get('vehicle_class') or default_vehicle_class,
'trim_level': asset_data.trim_level,
# Technical Specs
'fuel_type': asset_data.fuel_type or catalog_data.get('fuel_type'),
'engine_capacity': asset_data.engine_capacity or catalog_data.get('engine_capacity'),
'power_kw': asset_data.power_kw or catalog_data.get('power_kw'),
'torque_nm': asset_data.torque_nm,
'cylinder_layout': asset_data.cylinder_layout,
'transmission_type': asset_data.transmission_type,
'drive_type': asset_data.drive_type,
'euro_classification': asset_data.euro_classification or catalog_data.get('euro_classification'),
# Physical Dimensions
'curb_weight': asset_data.curb_weight,
'max_weight': asset_data.max_weight,
'cargo_volume_x': asset_data.cargo_volume_x,
'cargo_volume_y': asset_data.cargo_volume_y,
'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,
# Timeline
'year_of_manufacture': asset_data.year_of_manufacture,
'first_registration_date': asset_data.first_registration_date,
}
# Remove None values from the dictionary
asset_fields = {k: v for k, v in asset_fields.items() if v is not None}
new_asset = Asset(**asset_fields)
db.add(new_asset)
await db.flush()
# --- MATCHER INTEGRÁCIÓ (HA NINCS CATALOG_ID DE VANNAK ADATOK) ---
if not new_asset.catalog_id and new_asset.brand and new_asset.model:
from app.services.asset_matcher_service import AssetMatcherService
from app.models.vehicle.asset import AssetCatalog
print(f"DEBUG: Matcher args: make={new_asset.brand}, model={new_asset.model}, year={new_asset.year_of_manufacture}")
matched_result = await AssetMatcherService.find_best_match(db, new_asset)
if matched_result:
matched_def, conf = matched_result
if matched_def:
# FIX: Asset.catalog_id FK references vehicle.vehicle_catalog.id (AssetCatalog),
# NOT vehicle.vehicle_model_definitions.id (VehicleModelDefinition).
# Look up or create the AssetCatalog entry for this definition.
catalog_stmt = select(AssetCatalog).where(
AssetCatalog.master_definition_id == matched_def.id
).limit(1)
catalog_entry = (await db.execute(catalog_stmt)).scalar_one_or_none()
if not catalog_entry:
# Create a new AssetCatalog entry from the matched definition
catalog_entry = AssetCatalog(
master_definition_id=matched_def.id,
make=matched_def.make,
model=matched_def.marketing_name,
year_from=matched_def.year_from,
year_to=matched_def.year_to,
fuel_type=matched_def.fuel_type,
power_kw=matched_def.power_kw if matched_def.power_kw and matched_def.power_kw > 0 else None,
engine_capacity=matched_def.engine_capacity if matched_def.engine_capacity and matched_def.engine_capacity > 0 else None,
)
db.add(catalog_entry)
await db.flush()
new_asset.catalog_id = catalog_entry.id
await AssetMatcherService.enrich_asset_from_definition(db, new_asset, matched_def, conf)
await db.flush()
# Digitális Iker Alapmodulok
# Only create AssetAssignment if we have an organization_id
if target_org_id is not None:
db.add(AssetAssignment(asset_id=new_asset.id, organization_id=target_org_id, status="active"))
db.add(AssetTelemetry(asset_id=new_asset.id))
db.add(AssetFinancials(
asset_id=new_asset.id,
purchase_price_net=0.0,
purchase_price_gross=0.0,
financing_type="unknown"
))
# Gamification
reward = await config.get_setting(db, "xp_reward_asset_register", default=250)
await GamificationService.award_points(db, user_id, int(reward), "NEW_ASSET_REG")
# Check if this is user's first vehicle and award "First Car" badge
await AssetService._award_first_car_badge(db, user_id, target_org_id)
await db.commit()
from sqlalchemy.orm import selectinload
new_asset = (await db.execute(select(Asset).where(Asset.id == new_asset.id).options(selectinload(Asset.catalog)))).scalar_one()
return new_asset
except Exception as e:
await db.rollback()
logger.error(f"Asset Creation Error: {e}", exc_info=True)
raise e
@staticmethod
async def initiate_ownership_transfer(db: AsyncSession, asset: Asset, user_id: int, org_id: int, new_plate: str):
"""
Adásvétel kezelése: Az autót 'Transfer Pending' állapotba teszi.
"""
# Admin paraméter: Automatikus transzfer engedélyezése?
auto_transfer = await config.get_setting(db, "asset_auto_transfer_enabled", default=False)
# Logoljuk a kísérletet a biztonsági szolgálatnál (Sentinel)
await security_service.log_event(
db, user_id=user_id, action="VEHICLE_CLAIM_INITIATED",
severity=LogSeverity.warning, target_type="Asset", target_id=str(asset.id),
new_data={"vin": asset.vin, "new_org": org_id}
)
if auto_transfer:
# Csak akkor, ha a régi tulajdonos 'sold' állapotba tette
if asset.status == "sold":
return await AssetService.execute_final_transfer(db, asset, org_id, new_plate, user_id)
# Függőben lévő állapot: Dokumentum feltöltésre vár
asset.status = "transfer_pending"
asset.temp_claim_org_id = org_id # Átmeneti tároló a validálásig
await db.commit()
# Itt egy speciális hibaüzenetet dobunk, amit a Frontend tud kezelni (Dokumentum feltöltő ablak)
raise HTTPException(
status_code=202,
detail="A jármű már szerepel a rendszerben. Kérjük, töltsd fel az adásvételi szerződést a tulajdonjog igazolásához."
)
@staticmethod
async def execute_final_transfer(db: AsyncSession, asset: Asset, new_org_id: int, new_plate: str, user_id: int = None):
""" A tulajdonjog tényleges átírása az adatbázisban. """
# 1. Régi hozzárendelés lezárása
await db.execute(
update(AssetAssignment)
.where(and_(AssetAssignment.asset_id == asset.id, AssetAssignment.status == "active"))
.values(status="archived", end_date=datetime.now())
)
# 2. Új hozzárendelés és adatok frissítése
asset.current_organization_id = new_org_id
asset.license_plate = new_plate.upper()
asset.status = "active"
asset.is_verified = False # Az új tulajdonos papírjait is ellenőrizni kell!
# 3. Update ownership fields if user_id is provided
if user_id is not None:
from app.models.identity import User
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one_or_none()
if user and user.person_id:
asset.owner_person_id = user.person_id
asset.owner_org_id = new_org_id
else:
logger.warning(f"User {user_id} has no person_id, cannot set owner_person_id")
else:
logger.warning("execute_final_transfer called without user_id, ownership fields not updated")
# Only create AssetAssignment if we have an organization_id
if new_org_id is not None:
db.add(AssetAssignment(asset_id=asset.id, organization_id=new_org_id, status="active"))
await db.commit()
return asset
# --- CATALOG METHODS ---
@staticmethod
async def get_makes(db: AsyncSession, vehicle_class: Optional[str] = None) -> List[str]:
"""Get all distinct makes from vehicle model definitions, optionally filtered by vehicle_class.
Uses func.upper() to normalize brand casing and deduplicate case-insensitively.
"""
stmt = select(distinct(func.upper(VehicleModelDefinition.make))).order_by(func.upper(VehicleModelDefinition.make))
if vehicle_class:
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
result = await db.execute(stmt)
makes = result.scalars().all()
return [make for make in makes if make] # Filter out None/empty
@staticmethod
async def get_models(db: AsyncSession, make: str, vehicle_class: str = None) -> List[str]:
"""Get all distinct models for a given make, optionally filtered by vehicle_class.
Fuzzy matching: removes spaces and lowercases both the query and stored values
so that 'CB 1000' matches 'CB1000'.
"""
stmt = select(distinct(VehicleModelDefinition.marketing_name)).where(
func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(
f'%{make.replace(" ", "").lower()}%'
)
)
if vehicle_class:
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
stmt = stmt.order_by(VehicleModelDefinition.marketing_name)
result = await db.execute(stmt)
models = result.scalars().all()
return [model for model in models if model]
@staticmethod
async def get_generations(db: AsyncSession, make: str, model: str) -> List[str]:
"""Get all distinct generations/variants for a given make and model.
For now, we'll use engine_code as generation placeholder."""
stmt = select(distinct(VehicleModelDefinition.engine_code)).where(
VehicleModelDefinition.make == make,
VehicleModelDefinition.marketing_name == model,
VehicleModelDefinition.engine_code.isnot(None)
).order_by(VehicleModelDefinition.engine_code)
result = await db.execute(stmt)
generations = result.scalars().all()
return [gen for gen in generations if gen]
@staticmethod
async def get_engines(db: AsyncSession, make: str, model: str, gen: str) -> List[VehicleModelDefinition]:
"""Get all engine variants for a given make, model, and generation."""
stmt = select(VehicleModelDefinition).where(
VehicleModelDefinition.make == make,
VehicleModelDefinition.marketing_name == model,
VehicleModelDefinition.engine_code == gen
).order_by(VehicleModelDefinition.id)
result = await db.execute(stmt)
engines = result.scalars().all()
return engines
# --- CASCADING CATALOG METHODS (for wizard autocomplete) ---
@staticmethod
async def get_catalog_brands(db: AsyncSession, vehicle_class: Optional[str] = None, query: Optional[str] = None) -> List[str]:
"""Get all distinct brands (makes) from vehicle_model_definitions, with optional class filter and search query.
Uses func.upper() to normalize brand casing and deduplicate case-insensitively.
Fuzzy matching: removes spaces and lowercases both the query and stored values
so that 'BMW' matches 'bmw' and 'CB 1000' matches 'CB1000'.
"""
stmt = select(distinct(func.upper(VehicleModelDefinition.make))).order_by(func.upper(VehicleModelDefinition.make))
if vehicle_class:
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
if query:
clean_query = query.replace(' ', '').lower()
stmt = stmt.where(
func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(f'%{clean_query}%')
)
result = await db.execute(stmt)
brands = result.scalars().all()
return [b for b in brands if b]
@staticmethod
async def get_catalog_models(db: AsyncSession, brand: str, vehicle_class: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all distinct models for a given brand, returning id and marketing_name."""
stmt = select(
VehicleModelDefinition.id,
VehicleModelDefinition.marketing_name
).where(
VehicleModelDefinition.make == brand
).distinct(VehicleModelDefinition.marketing_name).order_by(VehicleModelDefinition.marketing_name)
if vehicle_class:
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
result = await db.execute(stmt)
rows = result.all()
return [{"id": r.id, "name": r.marketing_name} for r in rows if r.marketing_name]
@staticmethod
async def get_catalog_years(db: AsyncSession, brand: str, model: str) -> List[int]:
"""Get all distinct years for a given brand and model."""
stmt = select(distinct(VehicleModelDefinition.year_from)).where(
VehicleModelDefinition.make == brand,
VehicleModelDefinition.marketing_name == model,
VehicleModelDefinition.year_from > 0
).order_by(VehicleModelDefinition.year_from.desc())
result = await db.execute(stmt)
years = result.scalars().all()
return [y for y in years if y]
@staticmethod
async def get_catalog_trims(db: AsyncSession, brand: str, model: str, year: Optional[int] = None) -> List[Dict[str, Any]]:
"""Get all trim/variant details for a given brand, model, and optional year."""
stmt = select(VehicleModelDefinition).where(
VehicleModelDefinition.make == brand,
VehicleModelDefinition.marketing_name == model
)
if year and year > 0:
stmt = stmt.where(VehicleModelDefinition.year_from == year)
stmt = stmt.order_by(VehicleModelDefinition.trim_level, VehicleModelDefinition.engine_capacity)
result = await db.execute(stmt)
rows = result.scalars().all()
return [
{
"id": r.id,
"trim_level": r.trim_level or '',
"engine_capacity": r.engine_capacity or 0,
"power_kw": r.power_kw or 0,
"fuel_type": r.fuel_type,
"year_from": r.year_from,
"body_type": r.body_type or '',
}
for r in rows
]
@staticmethod
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
"""
Get the vehicle limit for a user, checking:
1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
2. Organization-level base_asset_limit (fallback if no user subscription)
3. Config-based limits (legacy fallback)
P0: The subscription_tier JSONB rules are now the Single Source of Truth.
Legacy string-based subscription_plan lookups have been removed.
Args:
db: AsyncSession
user_id: User ID
org_id: Organization ID
Returns:
Maximum allowed vehicles
"""
from app.models.identity import User
from app.models.core_logic import UserSubscription, SubscriptionTier
from app.models.marketplace.organization import Organization
from app.services.config_service import config
try:
# Get user info
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one()
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
# ── 1. SUBSCRIPTION TIER JSONB LIMIT (PRIMARY) ──
# P0: This is now the Single Source of Truth for vehicle limits.
# Reads rules['allowances']['max_vehicles'] from the user's active subscription tier.
subscription_limit = None
try:
sub_stmt = (
select(SubscriptionTier.rules)
.select_from(UserSubscription)
.join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id)
.where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
)
.limit(1)
)
sub_result = await db.execute(sub_stmt)
tier_rules = sub_result.scalar_one_or_none()
if tier_rules and isinstance(tier_rules, dict):
allowances = tier_rules.get('allowances', {})
if isinstance(allowances, dict):
max_vehicles = allowances.get('max_vehicles')
if max_vehicles is not None and isinstance(max_vehicles, (int, float)):
subscription_limit = int(max_vehicles)
logger.info(
f"[P0] Subscription tier limit for user {user_id}: "
f"max_vehicles={subscription_limit} (from JSONB rules)"
)
except Exception as e:
logger.debug(f"Could not read subscription tier limit for user {user_id}: {e}")
# ── 2. ORGANIZATION base_asset_limit (fallback) ──
# If no user-level subscription, check the org's assigned tier
org_limit = None
if subscription_limit is None:
try:
org_stmt = select(Organization).where(Organization.id == org_id)
org = (await db.execute(org_stmt)).scalar_one_or_none()
if org and org.subscription_tier_id:
# Read from the org's subscription tier
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
tier = (await db.execute(tier_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get('allowances', {})
if isinstance(allowances, dict):
max_vehicles = allowances.get('max_vehicles')
if max_vehicles is not None:
org_limit = int(max_vehicles)
logger.info(
f"[P0] Org subscription tier limit for org {org_id}: "
f"max_vehicles={org_limit} (from JSONB rules)"
)
elif org:
# Fallback to legacy base_asset_limit if no tier assigned
org_limit = max(org.base_asset_limit or 1, 1)
except Exception as e:
logger.debug(f"Could not read org subscription tier limit for org {org_id}: {e}")
# ── 3. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
config_limit = None
try:
limits = await config.get_setting(db, "VEHICLE_LIMIT")
if limits and isinstance(limits, dict):
config_limit = limits.get(user_role)
if config_limit is None:
config_limit = limits.get("default")
except Exception as e:
logger.debug(f"Could not read VEHICLE_LIMIT config: {e}")
if config_limit is None:
config_limit = 1 # absolute fallback
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
final_limit = config_limit
if org_limit is not None:
final_limit = max(final_limit, org_limit)
if subscription_limit is not None:
final_limit = max(final_limit, subscription_limit)
logger.info(
f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
f"subscription_tier={subscription_limit}, org={org_limit}, config={config_limit}, "
f"final={final_limit}"
)
return final_limit
except Exception as e:
logger.error(f"Error getting vehicle limit for user {user_id}, org {org_id}: {e}")
# Fallback to a reasonable default
return 100
@staticmethod
async def archive_vehicle(
db: AsyncSession,
asset_id: uuid.UUID,
user_id: int,
final_mileage: int,
archive_reason: str,
) -> Asset:
"""
Strict Soft Delete — Jármű biztonságos kivezetése a garázsból.
A művelet:
1. Frissíti a current_mileage értékét a megadott final_mileage-ra
2. Állítja: status = 'archived'
3. Nullázza az owner_person_id és owner_org_id mezőket
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
5. Naplózza a biztonsági auditba
"""
from app.models.identity import User
from sqlalchemy.orm import selectinload
# 1. Lekérjük a járművet — eager load catalog reláció a MissingGreenlet hiba elkerülésére
stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.catalog))
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Jármű nem található")
# 2. Jogosultság ellenőrzése — csak a tulajdonos vagy admin archiválhat
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="Felhasználó nem található")
is_owner = (asset.owner_person_id == user.person_id)
is_admin = user.role in ("admin", "superadmin") if hasattr(user, 'role') else False
if not is_owner and not is_admin:
raise HTTPException(
status_code=403,
detail="Nincs jogosultságod a jármű eltávolításához"
)
# 3. Már archiválva van?
if asset.status == "archived":
raise HTTPException(status_code=400, detail="A jármű már archiválva van")
# 4. Mentjük az archive_info metaadatokat
now_iso = datetime.now(timezone.utc).isoformat()
equipment = dict(asset.individual_equipment or {})
equipment['archive_info'] = {
'reason': archive_reason,
'archived_at': now_iso,
'previous_owner_person_id': asset.owner_person_id,
'previous_owner_org_id': asset.owner_org_id,
'final_mileage': final_mileage,
}
# 5. Frissítjük a jármű adatait
asset.current_mileage = final_mileage
asset.status = "archived"
asset.owner_person_id = None
asset.owner_org_id = None
asset.individual_equipment = equipment
asset.updated_at = datetime.now(timezone.utc)
# 6. Biztonsági audit naplózás
await security_service.log_event(
db,
user_id=user_id,
action="VEHICLE_ARCHIVED",
severity=LogSeverity.warning,
target_type="Asset",
target_id=str(asset.id),
new_data={
"reason": archive_reason,
"final_mileage": final_mileage,
"archived_at": now_iso,
}
)
await db.commit()
await db.refresh(asset)
logger.info(
f"Vehicle {asset.id} ({asset.license_plate}) archived by user {user_id}. "
f"Reason: {archive_reason}, final_mileage: {final_mileage}"
)
return asset
@staticmethod
async def _award_first_car_badge(db: AsyncSession, user_id: int, org_id: int):
"""
Award 'First Car' badge to user if this is their first vehicle.
Checks if the user already has any vehicles in the organization.
If not, awards the 'First Car' badge.
"""
try:
from sqlalchemy import select, func
from app.models.gamification import Badge, UserBadge
# Check if user already has vehicles in this organization
from app.models.vehicle import Asset
vehicle_count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == org_id,
Asset.status == "active"
)
vehicle_count = (await db.execute(vehicle_count_stmt)).scalar()
# If this is the first vehicle (count should be 1 after the new one is added)
if vehicle_count == 1:
# Get or create the "First Car" badge
badge_stmt = select(Badge).where(Badge.name == "First Car")
badge_result = await db.execute(badge_stmt)
badge = badge_result.scalar_one_or_none()
if not badge:
# Create the badge if it doesn't exist
badge = Badge(
name="First Car",
description="Awarded for adding your first vehicle to the fleet",
icon_url="/badges/first-car.svg"
)
db.add(badge)
await db.flush()
# Check if user already has this badge
user_badge_stmt = select(UserBadge).where(
UserBadge.user_id == user_id,
UserBadge.badge_id == badge.id
)
user_badge_result = await db.execute(user_badge_stmt)
existing_user_badge = user_badge_result.scalar_one_or_none()
if not existing_user_badge:
# Award the badge to the user
user_badge = UserBadge(
user_id=user_id,
badge_id=badge.id,
earned_at=datetime.utcnow()
)
db.add(user_badge)
await db.flush()
logger.info(f"Awarded 'First Car' badge to user {user_id}")
except Exception as e:
logger.error(f"Error awarding first car badge: {e}")
# Don't raise the error - badge awarding shouldn't break vehicle creation