admin frontend elkezdése, járművek tisztázása, frontend fejleszts

This commit is contained in:
Roo
2026-06-15 18:52:38 +00:00
parent ef8df9608c
commit 213ba3b0f1
80 changed files with 11615 additions and 2304 deletions

View File

@@ -228,12 +228,34 @@ class AssetService:
# --- 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:
new_asset.catalog_id = matched_def.id
# 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()
@@ -348,9 +370,15 @@ class AssetService:
@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."""
"""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(
VehicleModelDefinition.make == make
func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(
f'%{make.replace(" ", "").lower()}%'
)
)
if vehicle_class:
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
@@ -384,6 +412,78 @@ class AssetService:
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.
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(VehicleModelDefinition.make)).order_by(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:
"""
@@ -596,15 +696,13 @@ class AssetService:
user_badge = UserBadge(
user_id=user_id,
badge_id=badge.id,
awarded_at=datetime.utcnow()
earned_at=datetime.utcnow()
)
db.add(user_badge)
await db.flush()
logger = logging.getLogger(__name__)
logger.info(f"Awarded 'First Car' badge to user {user_id}")
except Exception as e:
logger = logging.getLogger(__name__)
logger.error(f"Error awarding first car badge: {e}")
# Don't raise the error - badge awarding shouldn't break vehicle creation

View File

@@ -9,8 +9,8 @@ from sqlalchemy.orm import joinedload
from fastapi.encoders import jsonable_encoder
from fastapi import HTTPException, status
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
from app.models import UserStats
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet, OneTimePassword
from app.models import UserStats, LogSeverity
from app.models.marketplace import Organization, OrganizationMember, OrgType, Branch
from app.schemas.auth import UserLiteRegister, UserKYCComplete
from app.core.security import get_password_hash, verify_password, generate_secure_slug
@@ -146,7 +146,7 @@ class AuthService:
# Sentinel Audit Log
await security_service.log_event(
db, user_id=new_user.id, action="USER_REGISTER_LITE",
severity="INFO", target_type="User", target_id=str(new_user.id),
severity=LogSeverity.info, target_type="User", target_id=str(new_user.id),
new_data={"email": user_in.email}
)
@@ -285,8 +285,8 @@ class AuthService:
if kyc_in.region_code:
user.region_code = kyc_in.region_code
# Gamification XP jóváírás
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION")
# Gamification XP jóváírás (commit=False, mert a külső metódus kezeli a tranzakciót)
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION", commit=False)
# P2P Referral XP a meghívónak (ha van referred_by_id)
if user.referred_by_id:
@@ -295,7 +295,8 @@ class AuthService:
db,
user_id=user.referred_by_id,
amount=int(p2p_xp),
reason="P2P_REFERRAL_SUCCESS"
reason="P2P_REFERRAL_SUCCESS",
commit=False
)
logger.info(f"P2P XP ({p2p_xp}) awarded to referrer ID {user.referred_by_id} for user {user.id}")
@@ -341,11 +342,15 @@ class AuthService:
user.is_active = True
# Activate person
person_stmt = select(Person).where(Person.user_id == user.id)
person = (await db.execute(person_stmt)).scalar_one_or_none()
if person:
person.is_active = True
# Activate person - FIX: Use User.person_id instead of Person.user_id
# Person.user_id is a separate FK that may be NULL
if user.person_id:
person_stmt = select(Person).where(Person.id == user.person_id)
person = (await db.execute(person_stmt)).scalar_one_or_none()
if person:
person.is_active = True
# Also set the back-reference so Person.user_id is consistent
person.user_id = user.id
await db.commit()
return user # Return the activated User object
@@ -379,20 +384,23 @@ class AuthService:
))
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
logger.info(f"Email küldés indítása (password reset) ide: {email}...")
result = await email_manager.send_email(
recipient=email, template_key="pwd_reset",
variables={"link": link}, lang=user.preferred_language
)
logger.info(f"Email küldés eredménye (password reset): {result}")
# Ha az email küldés hibát dobott, ne titkoljuk el
if result.get("status") == "error":
logger.error(f"Password reset email FAILED for {email}: {result.get('message')}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Email küldési hiba: {result.get('message')}"
# Mock email sending: log the reset link to console instead of sending real email
logger.info(f"*** MOCK EMAIL *** Password reset for {email}")
logger.info(f"*** MOCK EMAIL *** Reset link: {link}")
logger.info(f"*** MOCK EMAIL *** Token: {token_val}")
# Try to send real email, but don't crash if SMTP is unavailable
try:
result = await email_manager.send_email(
recipient=email, template_key="pwd_reset",
variables={"link": link}, lang=user.preferred_language
)
logger.info(f"Email küldés eredménye (password reset): {result}")
if result and result.get("status") == "error":
logger.warning(f"Password reset email sending failed (non-critical): {result.get('message')}")
except Exception as e:
logger.warning(f"Password reset email sending exception (non-critical): {e}")
await db.commit()
return "success"
@@ -424,7 +432,177 @@ class AuthService:
return True
except HTTPException:
raise
except: return False
except Exception as e:
logger.error(f"Password reset error: {e}")
return False
@staticmethod
async def _generate_otp_code() -> str:
"""Generate a random 6-digit OTP code."""
import random
return f"{random.randint(0, 999999):06d}"
@staticmethod
async def request_account_restore(db: AsyncSession, email: str):
"""
30 napos fiókvisszaállítás: OTP kérés.
Megkeresi a törölt felhasználót az eredeti email alapján.
Ha 30 napon belül van a törlés, generál 6-jegyű kódot.
Ha 30 napon TÚL van, jelzi, hogy új regisztráció szükséges.
"""
try:
# Search for deleted user by checking if email exists in deleted prefix pattern
stmt = select(User).where(
User.email.like(f"%{email}"),
User.is_deleted == True
)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return {"status": "not_found", "message": "Nem található törölt fiók ezzel az email címmel."}
# Check if within 30-day window
if user.deleted_at:
days_since_deletion = (datetime.now(timezone.utc) - user.deleted_at).days
else:
days_since_deletion = 0
if days_since_deletion > 30:
return {
"status": "expired",
"message": "A fiók véglegesen megsemmisült. Kérjük, regisztráljon újra ugyanazzal az email címmel.",
"can_re_register": True,
"original_email": email
}
# Generate 6-digit OTP code
code = await AuthService._generate_otp_code()
expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
# Store OTP
otp = OneTimePassword(
email=email,
code=code,
otp_type="restore",
extra_data={"user_id": user.id},
expires_at=expires_at
)
db.add(otp)
await db.commit()
# Mock email sending: log the OTP code to console instead of sending real email
logger.info(f"*** MOCK EMAIL *** OTP Code for {email}: {code}")
logger.info(f"*** MOCK EMAIL *** Expires at: {expires_at.isoformat()}")
return {
"status": "otp_sent",
"message": "Egy 6-jegyű visszaállítási kódot küldtünk az email címére.",
"expires_at": expires_at.isoformat()
}
except Exception as e:
await db.rollback()
logger.error(f"Account restore request error for {email}: {e}")
return {"status": "error", "message": "Hiba történt a visszaállítási kód generálása során."}
@staticmethod
async def verify_account_restore(db: AsyncSession, email: str, code: str):
"""
OTP kód ellenőrzése és fiók visszaállítása.
"""
now = datetime.now(timezone.utc)
# Find valid OTP
stmt = select(OneTimePassword).where(
OneTimePassword.email == email,
OneTimePassword.code == code,
OneTimePassword.otp_type == "restore",
OneTimePassword.is_used == False,
OneTimePassword.expires_at > now
).order_by(OneTimePassword.created_at.desc()).limit(1)
result = await db.execute(stmt)
otp = result.scalar_one_or_none()
if not otp:
return {"status": "invalid", "message": "Érvénytelen vagy lejárt kód."}
# Mark OTP as used
otp.is_used = True
# Find the deleted user
user_id = otp.extra_data.get("user_id") if otp.extra_data else None
if not user_id:
return {"status": "error", "message": "Hiba a fiók azonosításában."}
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if not user:
return {"status": "error", "message": "Felhasználó nem található."}
# Restore the account
user.is_deleted = False
user.is_active = True
user.deleted_at = None
# Restore original email (remove deleted_ prefix)
# The email format is: deleted_{id}_{timestamp}_{original_email}
if user.email.startswith("deleted_"):
parts = user.email.split("_", 3)
if len(parts) == 4:
user.email = parts[3]
await security_service.log_event(
db, user_id=user.id, action="USER_ACCOUNT_RESTORE",
severity=LogSeverity.info, target_type="User", target_id=str(user.id),
new_data={"restored_via": "otp", "email": user.email}
)
await db.commit()
return {"status": "success", "message": "Fiók sikeresen visszaállítva."}
@staticmethod
async def check_is_last_admin(db: AsyncSession, user_id: int) -> bool:
"""
Ellenőrzi, hogy a felhasználó az egyetlen aktív admin bármelyik szervezetében.
Visszaadja: is_last_admin: bool
"""
try:
from app.models.marketplace.organization import OrganizationMember, OrgUserRole
# Find organizations where user is ADMIN or OWNER
stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == user_id,
OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER])
)
result = await db.execute(stmt)
org_ids = [row[0] for row in result.all()]
if not org_ids:
return False
# For each org, check if there are other active admins/owners
for org_id in org_ids:
stmt = select(OrganizationMember).where(
OrganizationMember.organization_id == org_id,
OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER]),
OrganizationMember.user_id != user_id
)
result = await db.execute(stmt)
other_admins = result.scalars().all()
if not other_admins:
# This org has no other admin - user is last admin here
return True
return False
except HTTPException:
raise
except Exception as e:
logger.error(f"check_is_last_admin error: {e}")
return False
@staticmethod
async def resend_verification(db: AsyncSession, email: str):
@@ -479,20 +657,44 @@ class AuthService:
@staticmethod
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
""" Felhasználó törlése (Soft-Delete) auditálással. """
"""
Felhasználó törlése (Soft-Delete) auditálással, Person-preserving logikával.
Üzleti szabályok:
1. User: is_active=False, is_deleted=True, deleted_at=utcnow
2. Email átírás: deleted_{id}_{timestamp}_{original_email}
3. Person rekordot NEM bántja (megőrizzük a jövőbeli újraaktiváláshoz)
4. Wallet és Gamification adatok megőrzése (inaktív user miatt freeze)
5. Refresh token-ek érvénytelenítése (JWT-alapú, így a deleted_at middleware ellenőrzi)
"""
stmt = select(User).where(User.id == user_id)
user = (await db.execute(stmt)).scalar_one_or_none()
if not user or user.is_deleted: return False
if not user or user.is_deleted:
return False
# 1. Anonymize email with timestamp
old_email = user.email
user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}"
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
user.email = f"deleted_{user.id}_{timestamp}_{old_email}"
# 2. Set soft-delete flags
user.is_deleted = True
user.is_active = False
user.deleted_at = datetime.now(timezone.utc)
# 3. CRITICAL: Do NOT touch Person record
# Person.is_active stays as-is, Person data stays intact
# This allows future re-activation with the same identity
# 4. Security audit log with deleted_at info
await security_service.log_event(
db, user_id=actor_id, action="USER_SOFT_DELETE",
severity="WARNING", target_type="User", target_id=str(user_id),
new_data={"reason": reason}
severity=LogSeverity.warning, target_type="User", target_id=str(user_id),
new_data={
"reason": reason,
"deleted_at": str(user.deleted_at),
"person_preserved": True
}
)
await db.commit()
return True
return True

View File

@@ -18,28 +18,40 @@ class GamificationService:
"""
@staticmethod
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0):
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz. """
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True):
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz.
Args:
commit: If True (default), commits the transaction internally.
Set to False when called from within a larger transaction
(e.g., from AuthService.complete_kyc).
"""
service = GamificationService()
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason)
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit)
async def process_activity(
self,
db: AsyncSession,
user_id: int,
xp_amount: int,
social_amount: int,
reason: str,
is_penalty: bool = False
self,
db: AsyncSession,
user_id: int,
xp_amount: int,
social_amount: int,
reason: str,
is_penalty: bool = False,
commit: bool = True
):
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés. """
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés.
Args:
commit: If True (default), commits the transaction internally.
Set to False when called from within a larger transaction.
"""
try:
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
# Minden paraméter az admin felületről módosítható JSON-ként
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default={
"xp_logic": {"base_xp": 500, "exponent": 1.5},
"penalty_logic": {
"recovery_rate": 0.5,
"recovery_rate": 0.5,
"thresholds": {"level_1": 100, "level_2": 500, "level_3": 1000},
"multipliers": {"L0": 1.0, "L1": 0.5, "L2": 0.1, "L3": 0.0}
},
@@ -98,12 +110,15 @@ class GamificationService:
# 7. NAPLÓZÁS
db.add(PointsLedger(user_id=user_id, points=final_xp, reason=reason))
await db.commit()
await db.refresh(stats)
# Only commit if caller wants us to manage the transaction
if commit:
await db.commit()
await db.refresh(stats)
return stats
except Exception as e:
await db.rollback()
if commit:
await db.rollback()
logger.error(f"Gamification Error for user {user_id}: {e}")
raise e

View File

@@ -66,8 +66,10 @@ class MaintenanceService:
alerts_generated = 0
for asset, telemetry in result.all():
# A Robot 3 által feltöltött gyári adat (pl. 15.000 km)
interval = asset.factory_data.get("oil_change_km") if asset.factory_data else None
last_service_km = asset.factory_data.get("last_service_km", 0)
# A factory_data az AssetCatalog (vehicle_catalog) táblában van
catalog_data = asset.catalog.factory_data if asset.catalog else {}
interval = catalog_data.get("oil_change_km") if catalog_data else None
last_service_km = catalog_data.get("last_service_km", 0)
if interval and telemetry.current_mileage:
next_service_due = last_service_km + interval

View File

@@ -1,213 +1,15 @@
"""
Smart Odometer Service - Adminisztrátor által paraméterezhető kilométeróra becslés.
[DEPRECATED] Smart Odometer Service - VehicleOdometerState model removed.
A szolgáltatás a járművek kilométeróra állását becsüli a költségbejegyzések alapján,
figyelembe véve a rendszerparamétereket (ODOMETER_MIN_DAYS_FOR_AVG, ODOMETER_CONFIDENCE_THRESHOLD).
Ha az admin beállított manuális átlagot (manual_override_avg), akkor azt használja.
This service is deprecated. The VehicleOdometerState model has been removed
from the schema. Odometer tracking is now handled directly via the
OdometerReading table (audit trail) and Asset.current_mileage field.
The predictive odometer state estimation logic has been removed to keep
the entity model clean. Use OdometerReading records directly for
mileage audit trail purposes.
"""
from datetime import datetime, timedelta
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from sqlalchemy.orm import selectinload
from app.models.vehicle import VehicleOdometerState, VehicleCost
from app.models.system import SystemParameter
from app.models import VehicleModelDefinition
class OdometerService:
"""Kilométeróra becslési szolgáltatás adminisztrációs kontrollal."""
@staticmethod
async def get_system_param(db: AsyncSession, key: str, default_value):
"""Rendszerparaméter lekérése a system.system_parameters táblából."""
stmt = select(SystemParameter).where(
SystemParameter.key == key,
SystemParameter.scope_level == 'global',
SystemParameter.is_active == True
)
result = await db.execute(stmt)
param = result.scalars().first()
if param and 'value' in param.value:
return param.value['value']
return default_value
@staticmethod
async def update_vehicle_stats(db: AsyncSession, vehicle_id: int) -> Optional[VehicleOdometerState]:
"""
Frissíti a jármű kilométeróra statisztikáit.
Algoritmus:
1. Ha van manual_override_avg, használja azt.
2. Különben számol átlagot a vehicle.costs bejegyzésekből.
3. Figyelembe veszi az ODOMETER_MIN_DAYS_FOR_AVG paramétert.
4. Kiszámolja a confidence_score-t a minták száma alapján.
5. Frissíti vagy létrehozza a VehicleOdometerState rekordot.
"""
# Rendszerparaméterek lekérése
min_days = await OdometerService.get_system_param(db, 'ODOMETER_MIN_DAYS_FOR_AVG', 7)
confidence_threshold = await OdometerService.get_system_param(db, 'ODOMETER_CONFIDENCE_THRESHOLD', 0.5)
# Meglévő állapot lekérése
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
result = await db.execute(stmt)
odometer_state = result.scalars().first()
# Költségbejegyzések lekérése dátum és odometer szerint rendezve
cost_stmt = select(VehicleCost).where(
VehicleCost.vehicle_id == vehicle_id,
VehicleCost.odometer.isnot(None)
).order_by(VehicleCost.date.asc())
cost_result = await db.execute(cost_stmt)
costs = cost_result.scalars().all()
if not costs:
# Nincs adat, alapértelmezett értékek
if odometer_state:
odometer_state.daily_avg_distance = 0
odometer_state.confidence_score = 0
odometer_state.estimated_current_odometer = odometer_state.last_recorded_odometer
else:
# Jármű alapadatok lekérése
vehicle_stmt = select(VehicleModelDefinition).where(VehicleModelDefinition.id == vehicle_id)
vehicle_result = await db.execute(vehicle_stmt)
vehicle = vehicle_result.scalars().first()
if not vehicle:
return None
odometer_state = VehicleOdometerState(
vehicle_id=vehicle_id,
last_recorded_odometer=0,
last_recorded_date=datetime.now(),
daily_avg_distance=0,
estimated_current_odometer=0,
confidence_score=0,
manual_override_avg=None
)
db.add(odometer_state)
await db.commit()
await db.refresh(odometer_state)
return odometer_state
# Utolsó rögzített adatok
last_cost = costs[-1]
last_recorded_odometer = last_cost.odometer
last_recorded_date = last_cost.date
# Manuális átlag ellenőrzése
if odometer_state and odometer_state.manual_override_avg is not None:
daily_avg = float(odometer_state.manual_override_avg)
confidence = 1.0 # Manuális beállítás esetén teljes bizalom
else:
# Átlag számítása a költségbejegyzésekből
valid_pairs = []
for i in range(1, len(costs)):
prev = costs[i-1]
curr = costs[i]
days_diff = (curr.date - prev.date).days
km_diff = curr.odometer - prev.odometer
if days_diff >= min_days and km_diff > 0:
daily_avg = km_diff / days_diff
valid_pairs.append((daily_avg, days_diff))
if valid_pairs:
# Súlyozott átlag (hosszabb időszakok nagyobb súllyal)
total_weighted = sum(avg * weight for avg, weight in valid_pairs)
total_days = sum(weight for _, weight in valid_pairs)
daily_avg = total_weighted / total_days if total_days > 0 else 0
# Confidence score: érvényes párok száma / összes lehetséges párok
confidence = min(len(valid_pairs) / max(len(costs) - 1, 1), 1.0)
else:
daily_avg = 0
confidence = 0
# Becsült jelenlegi kilométer
days_since_last = (datetime.now(last_recorded_date.tzinfo) - last_recorded_date).days
estimated_odometer = last_recorded_odometer + (daily_avg * max(days_since_last, 0))
# Állapot frissítése vagy létrehozása
if odometer_state:
odometer_state.last_recorded_odometer = last_recorded_odometer
odometer_state.last_recorded_date = last_recorded_date
odometer_state.daily_avg_distance = daily_avg
odometer_state.estimated_current_odometer = estimated_odometer
odometer_state.confidence_score = confidence
else:
odometer_state = VehicleOdometerState(
vehicle_id=vehicle_id,
last_recorded_odometer=last_recorded_odometer,
last_recorded_date=last_recorded_date,
daily_avg_distance=daily_avg,
estimated_current_odometer=estimated_odometer,
confidence_score=confidence,
manual_override_avg=None
)
db.add(odometer_state)
await db.commit()
await db.refresh(odometer_state)
return odometer_state
@staticmethod
async def get_estimated_odometer(db: AsyncSession, vehicle_id: int) -> Tuple[Optional[float], float]:
"""
Visszaadja a jármű becsült jelenlegi kilométeróra állását és a bizalom pontszámot.
Returns:
Tuple[estimated_odometer, confidence_score]
"""
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
result = await db.execute(stmt)
odometer_state = result.scalars().first()
if not odometer_state:
# Ha nincs állapot, frissítsük
odometer_state = await OdometerService.update_vehicle_stats(db, vehicle_id)
if not odometer_state:
return None, 0.0
return odometer_state.estimated_current_odometer, odometer_state.confidence_score
@staticmethod
async def set_manual_override(db: AsyncSession, vehicle_id: int, daily_avg: Optional[float]) -> Optional[VehicleOdometerState]:
"""
Adminisztrátori manuális átlag beállítása.
Args:
daily_avg: Napi átlagos kilométer (km/nap). Ha None, törli a manuális beállítást.
"""
stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
result = await db.execute(stmt)
odometer_state = result.scalars().first()
if not odometer_state:
# Ha nincs állapot, hozzuk létre
odometer_state = VehicleOdometerState(
vehicle_id=vehicle_id,
last_recorded_odometer=0,
last_recorded_date=datetime.now(),
daily_avg_distance=0,
estimated_current_odometer=0,
confidence_score=0,
manual_override_avg=daily_avg
)
db.add(odometer_state)
else:
odometer_state.manual_override_avg = daily_avg
# Frissítsük a becslést a manuális átlaggal
if daily_avg is not None:
days_since_last = (datetime.now(odometer_state.last_recorded_date.tzinfo) - odometer_state.last_recorded_date).days
odometer_state.estimated_current_odometer = odometer_state.last_recorded_odometer + (daily_avg * max(days_since_last, 0))
odometer_state.confidence_score = 1.0
await db.commit()
await db.refresh(odometer_state)
return odometer_state
import logging
logger = logging.getLogger(__name__)
logger.warning("OdometerService is deprecated. VehicleOdometerState model has been removed.")

View File

@@ -0,0 +1,162 @@
# /opt/docker/dev/service_finder/backend/app/services/subscription_service.py
"""
Subscription & Entitlement Service — Premium költségkategória láthatóság.
A szolgáltatás a felhasználó előfizetési szintje (tier) alapján szabályozza,
hogy mely költségkategóriák (CostCategory) érhetők el. A tier-ek hierarchikusak:
free < premium < enterprise.
Hierarchia:
- free: alap kategóriák (üzemanyag, szerviz, gumik, biztosítás, adók)
- premium: free + útdíj/parkolás, ápolás, egyéb
- enterprise: premium + minden (nincs korlátozás)
"""
from __future__ import annotations
import logging
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.vehicle.vehicle import CostCategory
logger = logging.getLogger(__name__)
# ── Subscription Feature Matrix ──────────────────────────────────────────────
# Minden funkció/kategória minimális tier szintje.
# A tier-ek: free < premium < enterprise
TIER_HIERARCHY = {
"free": 0,
"premium": 1,
"enterprise": 2,
}
SUBSCRIPTION_FEATURES: dict[str, str] = {
# Feature -> minimum required tier
"cost_category:fuel": "free",
"cost_category:service": "free",
"cost_category:tires": "free",
"cost_category:insurance": "free",
"cost_category:taxes": "free",
"cost_category:toll_parking": "premium",
"cost_category:cleaning": "premium",
"cost_category:other": "premium",
"cost_category:all": "enterprise",
# Feature flags
"export_csv": "free",
"analytics_tco": "premium",
"analytics_detailed": "enterprise",
"api_access": "free",
"multi_vehicle": "free",
"unlimited_vehicles": "premium",
}
class SubscriptionService:
"""
Entitlement Mátrix — előfizetési szintek kezelése.
Metódusok:
get_user_tier(db, user_id) -> str
can_access_feature(tier, feature_key) -> bool
get_visible_categories(db, tier) -> list[CostCategory]
require_tier(tier, required_feature_or_min_tier) -> bool
"""
@staticmethod
def _tier_to_int(tier: str) -> int:
"""Tier string konvertálása numerikus értékre a hierarchiában."""
return TIER_HIERARCHY.get(tier.lower(), 0)
@staticmethod
async def get_user_tier(db: AsyncSession, user_id: int) -> str:
"""
Visszaadja a felhasználó aktuális előfizetési szintjét.
Jelenlegi implementáció: minden user 'free' tier-t kap.
A jövőben ez a subscription/org táblákból lesz lekérdezve.
"""
# TODO: Éles implementáció — subscription tábla lekérdezése
# TODO: Szervezeti tier felülírás (pl. enterprise org)
return "free"
@staticmethod
def can_access_feature(user_tier: str, feature_key: str) -> bool:
"""
Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz.
Args:
user_tier: A felhasználó tier szintje (free/premium/enterprise)
feature_key: A funkció kulcsa (pl. 'cost_category:fuel', 'analytics_tco')
Returns:
bool: True ha hozzáfér, False ha nem
"""
required_tier = SUBSCRIPTION_FEATURES.get(feature_key)
if required_tier is None:
# Ismeretlen feature — alapértelmezetten tiltva
logger.warning(f"Unknown feature key: {feature_key}")
return False
user_level = SubscriptionService._tier_to_int(user_tier)
required_level = SubscriptionService._tier_to_int(required_tier)
return user_level >= required_level
@staticmethod
async def get_visible_categories(
db: AsyncSession,
user_tier: str
) -> List[CostCategory]:
"""
Visszaadja azokat a CostCategory-ket, amelyek a user tier-jének megfelelnek.
Szűrési logika:
- A kategória min_tier mezője alapján szűrünk.
- Ha a kategória min_tier értéke kisebb vagy egyenlő, mint a user tier,
akkor látható.
- Ha a min_tier nincs beállítva (None), akkor 'free'-ként kezeljük.
Args:
db: Adatbázis session
user_tier: A felhasználó tier szintje
Returns:
List[CostCategory]: A látható kategóriák listája
"""
user_level = SubscriptionService._tier_to_int(user_tier)
# Lekérjük az összes kategóriát
stmt = select(CostCategory).order_by(CostCategory.id)
result = await db.execute(stmt)
all_categories = result.scalars().all()
# Szűrés a min_tier alapján
visible = []
for cat in all_categories:
cat_tier = getattr(cat, "min_tier", "free") or "free"
cat_level = SubscriptionService._tier_to_int(cat_tier)
if user_level >= cat_level:
visible.append(cat)
return visible
@staticmethod
def require_tier(user_tier: str, required_feature_or_min_tier: str) -> bool:
"""
Egyszerű tier ellenőrzés — dependency check-hez.
Args:
user_tier: A felhasználó tier szintje
required_feature_or_min_tier: Vagy egy feature kulcs, vagy egy tier név
Returns:
bool: True ha megfelel, False ha nem
"""
# Először feature kulcsként próbáljuk
if required_feature_or_min_tier in SUBSCRIPTION_FEATURES:
return SubscriptionService.can_access_feature(
user_tier, required_feature_or_min_tier
)
# Ha nem feature kulcs, akkor tier névként kezeljük
user_level = SubscriptionService._tier_to_int(user_tier)
required_level = SubscriptionService._tier_to_int(required_feature_or_min_tier)
return user_level >= required_level