797 lines
38 KiB
Python
Executable File
797 lines
38 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. P0 BOOSTER: extra_allowances from finance.org_subscriptions (additive on top of tier)
|
|
4. Config-based limits (legacy fallback)
|
|
|
|
P0: The subscription_tier JSONB rules are now the Single Source of Truth.
|
|
P0 Booster: extra_allowances.extra_vehicles is added on top of the tier limit.
|
|
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, OrganizationSubscription
|
|
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. P0 BOOSTER: extra_allowances from finance.org_subscriptions ──
|
|
# Extra allowances are additive on top of the tier limit.
|
|
# Read from the active org subscription's extra_allowances JSONB.
|
|
extra_vehicles = 0
|
|
try:
|
|
org_sub_stmt = (
|
|
select(OrganizationSubscription.extra_allowances)
|
|
.where(
|
|
OrganizationSubscription.org_id == org_id,
|
|
OrganizationSubscription.is_active == True
|
|
)
|
|
.limit(1)
|
|
)
|
|
org_sub_result = await db.execute(org_sub_stmt)
|
|
extra_allowances = org_sub_result.scalar_one_or_none()
|
|
|
|
if extra_allowances and isinstance(extra_allowances, dict):
|
|
extra_vehicles = int(extra_allowances.get('extra_vehicles', 0))
|
|
if extra_vehicles > 0:
|
|
logger.info(
|
|
f"[P0 BOOSTER] Extra vehicles for org {org_id}: "
|
|
f"extra_vehicles={extra_vehicles} (from extra_allowances JSONB)"
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Could not read extra_allowances for org {org_id}: {e}")
|
|
|
|
# ── 4. 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
|
|
|
|
# ── 5. FINAL: Take the HIGHEST of all applicable limits, then ADD boosters ──
|
|
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)
|
|
|
|
# Add extra_allowances on top of the base limit
|
|
final_limit = final_limit + extra_vehicles
|
|
|
|
logger.info(
|
|
f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
|
|
f"subscription_tier={subscription_limit}, org={org_limit}, "
|
|
f"extra_vehicles={extra_vehicles}, 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 |