refaktorálás javításai

This commit is contained in:
Roo
2026-03-13 10:22:41 +00:00
parent 2d8d23f469
commit f53e0b53df
140 changed files with 7316 additions and 4579 deletions

View File

@@ -0,0 +1,441 @@
# /opt/docker/dev/service_finder/backend/app/services/analytics_service.py
"""
TCO (Total Cost of Ownership) Analytics Service.
Számítások a vehicle.costs tábla alapján, árfolyam-átváltással a system_service segítségével.
"""
import logging
from typing import Optional, Dict, Any, List
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.vehicle import VehicleCost, CostCategory
from app.models.vehicle_definitions import VehicleModelDefinition
from app.models.organization import Organization
from app.services.system_service import SystemService
logger = logging.getLogger(__name__)
class TCOAnalytics:
"""
TCO Analytics osztály 3 fő metódussal:
1. get_user_tco: Egy adott organization_id költségeinek összesítése
2. get_vehicle_lifetime_tco: Egy jármű összes tulajdonos költségének összesítése (anonimizálva)
3. get_global_benchmark: Egy modell (vehicle_model_id) átlagos költségeinek számítása
"""
def __init__(self):
self.system_service = SystemService()
async def get_user_tco(
self,
db: AsyncSession,
organization_id: int,
currency_target: str = "HUF",
include_categories: Optional[List[str]] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> Dict[str, Any]:
"""
Egy adott szervezet (organization_id) összes költségének összesítése.
Átváltja a különböző valutákban lévő költségeket a célvalutára (currency_target).
:param db: Adatbázis munkamenet
:param organization_id: A szervezet azonosítója
:param currency_target: Célvaluta (pl. "HUF", "EUR")
:param include_categories: Szűrés költségkategóriákra (opcionális)
:param start_date: Kezdő dátum (ISO formátum, opcionális)
:param end_date: Végdátum (ISO formátum, opcionális)
:return: Szótár a következőkkel:
- total_amount: Összesített összeg a célvalutában
- total_transactions: Tranzakciók száma
- by_category: Kategóriánkénti bontás
- currency: A célvaluta
"""
# Alap lekérdezés: organization_id szűrés
stmt = select(
VehicleCost.amount,
VehicleCost.currency,
VehicleCost.category_id,
CostCategory.code,
CostCategory.name
).join(
CostCategory, VehicleCost.category_id == CostCategory.id
).where(
VehicleCost.organization_id == organization_id
)
# Dátum szűrés
if start_date:
stmt = stmt.where(VehicleCost.date >= start_date)
if end_date:
stmt = stmt.where(VehicleCost.date <= end_date)
# Kategória szűrés
if include_categories:
stmt = stmt.where(CostCategory.code.in_(include_categories))
result = await db.execute(stmt)
rows = result.all()
# Árfolyamok lekérése a system_service-ből
exchange_rates = await self._get_exchange_rates(db, currency_target)
total_amount = 0.0
category_totals = {}
for row in rows:
amount = float(row.amount)
source_currency = row.currency
# Átváltás célvalutára
converted_amount = await self._convert_currency(
db, amount, source_currency, currency_target, exchange_rates
)
total_amount += converted_amount
# Kategória összesítés
category_code = row.code
if category_code not in category_totals:
category_totals[category_code] = {
"name": row.name,
"total": 0.0,
"count": 0
}
category_totals[category_code]["total"] += converted_amount
category_totals[category_code]["count"] += 1
return {
"organization_id": organization_id,
"total_amount": round(total_amount, 2),
"total_transactions": len(rows),
"currency": currency_target,
"by_category": category_totals,
"date_range": {
"start": start_date,
"end": end_date
}
}
async def get_vehicle_lifetime_tco(
self,
db: AsyncSession,
vehicle_model_id: int,
currency_target: str = "HUF",
anonymize: bool = True,
) -> Dict[str, Any]:
"""
Egy jármű (vehicle_model_id) összes tulajdonos általi költségének összesítése.
Alapértelmezetten anonimizálva (organization_id-k elrejtve).
:param db: Adatbázis munkamenet
:param vehicle_model_id: A járműmodell azonosítója
:param currency_target: Célvaluta (pl. "HUF", "EUR")
:param anonymize: Ha True, nem tartalmazza az organization_id-kat
:return: Szótár a következőkkel:
- vehicle_model_id: A járműmodell azonosítója
- total_lifetime_cost: Teljes élettartam költség a célvalutában
- total_owners: Különböző tulajdonosok száma
- average_cost_per_owner: Tulajdonosonkénti átlag
- by_owner: Tulajdonosonkénti bontás (ha anonymize=False)
- currency: A célvaluta
"""
# Összes költség lekérdezése a járműhöz
stmt = select(
VehicleCost.amount,
VehicleCost.currency,
VehicleCost.organization_id,
Organization.name.label("org_name")
).outerjoin(
Organization, VehicleCost.organization_id == Organization.id
).where(
VehicleCost.vehicle_id == vehicle_model_id
)
result = await db.execute(stmt)
rows = result.all()
# Árfolyamok lekérése
exchange_rates = await self._get_exchange_rates(db, currency_target)
total_lifetime_cost = 0.0
owners = set()
owner_totals = {}
for row in rows:
amount = float(row.amount)
source_currency = row.currency
# Átváltás célvalutára
converted_amount = await self._convert_currency(
db, amount, source_currency, currency_target, exchange_rates
)
total_lifetime_cost += converted_amount
# Tulajdonos adatok
org_id = row.organization_id
if org_id:
owners.add(org_id)
if not anonymize:
if org_id not in owner_totals:
owner_totals[org_id] = {
"name": row.org_name,
"total": 0.0,
"count": 0
}
owner_totals[org_id]["total"] += converted_amount
owner_totals[org_id]["count"] += 1
total_owners = len(owners)
average_cost_per_owner = round(total_lifetime_cost / max(total_owners, 1), 2)
result_data = {
"vehicle_model_id": vehicle_model_id,
"total_lifetime_cost": round(total_lifetime_cost, 2),
"total_owners": total_owners,
"average_cost_per_owner": average_cost_per_owner,
"currency": currency_target,
"anonymized": anonymize,
}
if not anonymize:
result_data["by_owner"] = owner_totals
return result_data
async def get_global_benchmark(
self,
db: AsyncSession,
vehicle_model_id: Optional[int] = None,
make: Optional[str] = None,
model: Optional[str] = None,
fuel_type: Optional[str] = None,
currency_target: str = "HUF",
) -> Dict[str, Any]:
"""
Egy modell (vehicle_model_id) vagy modellcsoport átlagos költségeinek számítása.
Ha vehicle_model_id nincs megadva, akkor make/model/fuel_type alapján csoportosít.
:param db: Adatbázis munkamenet
:param vehicle_model_id: Konkrét járműmodell azonosítója (opcionális)
:param make: Gyártó (opcionális)
:param model: Modell (opcionális)
:param fuel_type: Üzemanyag típus (opcionális)
:param currency_target: Célvaluta (pl. "HUF", "EUR")
:return: Szótár a következőkkel:
- benchmark_type: "specific_model" vagy "grouped"
- vehicle_count: Járművek száma a mintában
- total_cost_sum: Összes költség a célvalutában
- average_cost_per_vehicle: Járművenkénti átlag
- average_cost_per_km: Kilométerenkénti átlag (ha elérhető odometer adat)
- by_category: Kategóriánkénti átlagok
- currency: A célvaluta
"""
# Alap lekérdezés: vehicle és cost összekapcsolása
stmt = select(
VehicleCost.amount,
VehicleCost.currency,
VehicleCost.vehicle_id,
VehicleCost.odometer,
CostCategory.code,
VehicleModelDefinition.make,
VehicleModelDefinition.model,
VehicleModelDefinition.fuel_type
).join(
VehicleModelDefinition, VehicleCost.vehicle_id == VehicleModelDefinition.id
).join(
CostCategory, VehicleCost.category_id == CostCategory.id
)
# Szűrés
if vehicle_model_id:
stmt = stmt.where(VehicleCost.vehicle_id == vehicle_model_id)
benchmark_type = "specific_model"
else:
conditions = []
if make:
conditions.append(VehicleModelDefinition.make == make)
if model:
conditions.append(VehicleModelDefinition.model == model)
if fuel_type:
conditions.append(VehicleModelDefinition.fuel_type == fuel_type)
if conditions:
stmt = stmt.where(and_(*conditions))
benchmark_type = "grouped"
result = await db.execute(stmt)
rows = result.all()
if not rows:
return {
"benchmark_type": benchmark_type,
"vehicle_count": 0,
"total_cost_sum": 0.0,
"average_cost_per_vehicle": 0.0,
"average_cost_per_km": None,
"by_category": {},
"currency": currency_target,
"message": "No data found for the specified criteria"
}
# Árfolyamok
exchange_rates = await self._get_exchange_rates(db, currency_target)
total_cost_sum = 0.0
total_odometer_sum = 0
vehicle_ids = set()
category_totals = {}
category_counts = {}
for row in rows:
amount = float(row.amount)
source_currency = row.currency
# Átváltás
converted_amount = await self._convert_currency(
db, amount, source_currency, currency_target, exchange_rates
)
total_cost_sum += converted_amount
vehicle_ids.add(row.vehicle_id)
# Odometer összegzés (ha van)
if row.odometer:
total_odometer_sum += row.odometer
# Kategória összesítés
category_code = row.code
if category_code not in category_totals:
category_totals[category_code] = 0.0
category_counts[category_code] = 0
category_totals[category_code] += converted_amount
category_counts[category_code] += 1
vehicle_count = len(vehicle_ids)
average_cost_per_vehicle = round(total_cost_sum / vehicle_count, 2)
# Kilométerenkénti átlag számítása
average_cost_per_km = None
if total_odometer_sum > 0:
average_cost_per_km = round(total_cost_sum / total_odometer_sum, 4)
# Kategóriánkénti átlagok
category_averages = {}
for code, total in category_totals.items():
count = category_counts[code]
category_averages[code] = {
"total": round(total, 2),
"count": count,
"average": round(total / count, 2)
}
return {
"benchmark_type": benchmark_type,
"vehicle_count": vehicle_count,
"total_cost_sum": round(total_cost_sum, 2),
"average_cost_per_vehicle": average_cost_per_vehicle,
"average_cost_per_km": average_cost_per_km,
"by_category": category_averages,
"currency": currency_target,
"criteria": {
"vehicle_model_id": vehicle_model_id,
"make": make,
"model": model,
"fuel_type": fuel_type
}
}
async def _get_exchange_rates(
self,
db: AsyncSession,
target_currency: str
) -> Dict[str, float]:
"""
Árfolyamok lekérése a system_service-ből.
A rendszerparaméterekben az "exchange_rates" kulcs alatt tároljuk.
:param db: Adatbázis munkamenet
:param target_currency: Célvaluta
:return: Szótár forrásvaluta -> célvaluta árfolyammal
"""
exchange_rates = await self.system_service.get_scoped_parameter(
db,
key="exchange_rates",
default={}
)
# Ha nincs adat, alapértelmezett árfolyamok
if not exchange_rates:
logger.warning("No exchange rates found in system parameters, using defaults")
# Alapértelmezett árfolyamok (1 EUR = 400 HUF, 1 USD = 350 HUF stb.)
exchange_rates = {
"EUR": {"HUF": 400.0, "EUR": 1.0, "USD": 1.1},
"USD": {"HUF": 350.0, "EUR": 0.9, "USD": 1.0},
"HUF": {"HUF": 1.0, "EUR": 0.0025, "USD": 0.0029},
"GBP": {"HUF": 460.0, "EUR": 1.15, "USD": 1.26},
}
# Ellenőrizzük, hogy a célvaluta szerepel-e az árfolyamokban
if target_currency not in exchange_rates.get("EUR", {}):
logger.warning(f"Target currency {target_currency} not found in exchange rates, using 1:1 conversion")
return exchange_rates
async def _convert_currency(
self,
db: AsyncSession,
amount: float,
source_currency: str,
target_currency: str,
exchange_rates: Dict[str, Any]
) -> float:
"""
Pénznem átváltása a megadott árfolyamok alapján.
:param amount: Összeg a forrásvalutában
:param source_currency: Forrásvaluta (pl. "EUR")
:param target_currency: Célvaluta (pl. "HUF")
:param exchange_rates: Árfolyam szótár
:return: Átváltott összeg a célvalutában
"""
if source_currency == target_currency:
return amount
# Keresés az árfolyamokban
try:
# Próbáljuk meg a forrásvaluta -> célvaluta árfolyamot
if source_currency in exchange_rates:
rates = exchange_rates[source_currency]
if target_currency in rates:
rate = rates[target_currency]
return amount * rate
# Ha nem találjuk, próbáljuk meg fordítva (inverz)
if target_currency in exchange_rates:
rates = exchange_rates[target_currency]
if source_currency in rates:
rate = 1.0 / rates[source_currency]
return amount * rate
# Ha még mindig nem találjuk, használjunk EUR-t közvetítőként
if "EUR" in exchange_rates:
eur_rates = exchange_rates["EUR"]
if source_currency in eur_rates and target_currency in eur_rates:
# Forrás -> EUR -> Cél
to_eur = amount / eur_rates[source_currency]
return to_eur * eur_rates[target_currency]
except (KeyError, ZeroDivisionError, TypeError) as e:
logger.error(f"Currency conversion error: {e}, using 1:1 conversion")
# Visszaesés: 1:1 árfolyam
logger.warning(f"Could not convert {source_currency} to {target_currency}, using 1:1 conversion")
return amount

View File

@@ -0,0 +1,183 @@
"""
DeduplicationService - Explicit deduplikáció a márka, technikai kód és jármű típus alapján.
Integrálja a mapping_rules.py és mapping_dictionary.py fájlokat.
"""
import logging
from typing import Optional, Dict, Any
from sqlalchemy import select, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.vehicle_definitions import VehicleModelDefinition
from app.workers.vehicle.mapping_rules import SOURCE_MAPPINGS, unify_data
logger = logging.getLogger(__name__)
# Ha nincs mapping_dictionary, hozzunk létre egy egyszerű szinonima szótárt
MAPPING_DICTIONARY = {
"make_synonyms": {
"BMW": ["BMW", "Bayerische Motoren Werke"],
"MERCEDES": ["MERCEDES", "MERCEDES-BENZ", "MERCEDES BENZ"],
"VOLKSWAGEN": ["VOLKSWAGEN", "VW"],
"AUDI": ["AUDI"],
"TOYOTA": ["TOYOTA"],
"FORD": ["FORD"],
# További márkák...
},
"technical_code_synonyms": {
# Példa: "1.8 TSI" -> ["1.8 TSI", "1.8TSI", "1.8 TSI 180"]
},
"vehicle_class_synonyms": {
"SUV": ["SUV", "SPORT UTILITY VEHICLE"],
"SEDAN": ["SEDAN", "SALOON"],
"HATCHBACK": ["HATCHBACK", "HATCH"],
"COUPE": ["COUPE", "COUPÉ"],
}
}
class DeduplicationService:
"""Szolgáltatás a duplikált járműmodell rekordok azonosítására és kezelésére."""
@staticmethod
def normalize_make(make: str) -> str:
"""Normalizálja a márka nevet a szinonimák alapján."""
make_upper = make.strip().upper()
for canonical, synonyms in MAPPING_DICTIONARY["make_synonyms"].items():
if make_upper in synonyms or make_upper == canonical:
return canonical
return make_upper
@staticmethod
def normalize_technical_code(technical_code: Optional[str]) -> str:
"""Normalizálja a technikai kódot (pl. motor kód)."""
if not technical_code:
return ""
# Egyszerű whitespace és pont eltávolítás
code = technical_code.strip().upper()
# További normalizáció: eltávolítás speciális karakterek
import re
code = re.sub(r'[^A-Z0-9]', '', code)
return code
@staticmethod
def normalize_vehicle_class(vehicle_class: Optional[str]) -> str:
"""Normalizálja a jármű osztályt."""
if not vehicle_class:
return ""
class_upper = vehicle_class.strip().upper()
for canonical, synonyms in MAPPING_DICTIONARY["vehicle_class_synonyms"].items():
if class_upper in synonyms or class_upper == canonical:
return canonical
return class_upper
@classmethod
async def find_duplicate(
cls,
session: AsyncSession,
make: str,
technical_code: str,
vehicle_class: str,
exclude_id: Optional[int] = None
) -> Optional[VehicleModelDefinition]:
"""
Megkeresi, hogy létezik-e már ugyanilyen (normalizált) rekord a vehicle_model_definitions táblában.
Args:
session: SQLAlchemy async session
make: márka (pl. "BMW")
technical_code: technikai kód (pl. "N47")
vehicle_class: jármű osztály (pl. "SEDAN")
exclude_id: kizárni kívánt rekord ID (pl. frissítésnél)
Returns:
VehicleModelDefinition instance ha talált duplikátumot, egyébként None.
"""
norm_make = cls.normalize_make(make)
norm_technical_code = cls.normalize_technical_code(technical_code)
norm_vehicle_class = cls.normalize_vehicle_class(vehicle_class)
# Keresés a normalizált értékek alapján
stmt = select(VehicleModelDefinition).where(
and_(
VehicleModelDefinition.make.ilike(f"%{norm_make}%"),
VehicleModelDefinition.technical_code.ilike(f"%{norm_technical_code}%"),
VehicleModelDefinition.vehicle_class.ilike(f"%{norm_vehicle_class}%")
)
)
if exclude_id:
stmt = stmt.where(VehicleModelDefinition.id != exclude_id)
result = await session.execute(stmt)
duplicate = result.scalar_one_or_none()
if duplicate:
logger.info(f"Duplikátum találva: ID {duplicate.id} - {duplicate.make} {duplicate.technical_code} {duplicate.vehicle_class}")
return duplicate
@classmethod
async def ensure_no_duplicate(
cls,
session: AsyncSession,
make: str,
technical_code: str,
vehicle_class: str,
exclude_id: Optional[int] = None
) -> bool:
"""
Ellenőrzi, hogy nincs-e duplikátum. Ha van, False-t ad vissza.
"""
duplicate = await cls.find_duplicate(session, make, technical_code, vehicle_class, exclude_id)
return duplicate is None
@classmethod
async def deduplicate_and_merge(
cls,
session: AsyncSession,
new_record: Dict[str, Any],
source_name: str = "manual"
) -> Dict[str, Any]:
"""
Duplikáció ellenőrzése és esetleges merge logika.
Ha talál duplikátumot, visszaadja a meglévő rekord adatait.
Ha nem, visszaadja a normalizált új rekordot.
Args:
session: SQLAlchemy async session
new_record: új rekord adatai (make, technical_code, vehicle_class, stb.)
source_name: adatforrás neve a mapping_rules-hoz
Returns:
Dict with keys:
- is_duplicate: bool
- existing_id: int if duplicate else None
- normalized_data: normalizált adatok
"""
# Normalizálás mapping_rules segítségével
unified = unify_data(new_record, source_name)
make = unified.get("normalized_make", new_record.get("make", ""))
technical_code = new_record.get("technical_code", "")
vehicle_class = new_record.get("vehicle_class", "")
duplicate = await cls.find_duplicate(session, make, technical_code, vehicle_class)
if duplicate:
return {
"is_duplicate": True,
"existing_id": duplicate.id,
"normalized_data": {
"make": duplicate.make,
"technical_code": duplicate.technical_code,
"vehicle_class": duplicate.vehicle_class,
}
}
# Nincs duplikátum, normalizált adatokkal tér vissza
return {
"is_duplicate": False,
"existing_id": None,
"normalized_data": {
"make": cls.normalize_make(make),
"technical_code": cls.normalize_technical_code(technical_code),
"vehicle_class": cls.normalize_vehicle_class(vehicle_class),
}
}

View File

@@ -0,0 +1,187 @@
"""
Financial Interfaces - Absztrakt alaposztályok a fizetési és számlázási szolgáltatásokhoz.
Ez a modul definiálja a kötelező interfészeket, amelyeket minden konkrét implementációnak
követnie kell a fizetési átjárók és számlázási szolgáltatások esetében.
"""
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any
from decimal import Decimal
class BasePaymentGateway(ABC):
"""
Absztrakt osztály fizetési átjárók számára.
Minden fizetési szolgáltató (Stripe, PayPal, stb.) implementálja ezt az interfészt,
hogy a FinancialOrchestrator egységesen kezelhesse őket.
"""
@abstractmethod
async def create_intent(
self,
amount: Decimal,
currency: str = "HUF",
metadata: Optional[Dict[str, Any]] = None,
**kwargs
) -> Dict[str, Any]:
"""
Fizetési szándék létrehozása a külső szolgáltatónál.
Args:
amount: A fizetendő összeg
currency: Pénznem (alapértelmezett: HUF)
metadata: Egyéni metaadatok
**kwargs: További paraméterek a konkrét implementáció számára
Returns:
Szótár a fizetési szándék adataival (pl. client_secret, id, status)
Raises:
PaymentGatewayError: Ha a fizetési szándék létrehozása sikertelen
"""
pass
@abstractmethod
async def verify_payment(
self,
payment_intent_id: str,
**kwargs
) -> Dict[str, Any]:
"""
Fizetés státuszának ellenőrzése a külső szolgáltatónál.
Args:
payment_intent_id: A fizetési szándék azonosítója
**kwargs: További paraméterek
Returns:
Szótár a fizetés részleteivel (pl. status, amount, customer)
Raises:
PaymentGatewayError: Ha az ellenőrzés sikertelen
"""
pass
@abstractmethod
async def refund_payment(
self,
payment_intent_id: str,
amount: Optional[Decimal] = None,
**kwargs
) -> Dict[str, Any]:
"""
Fizetés visszatérítése.
Args:
payment_intent_id: A fizetési szándék azonosítója
amount: Visszatérítendő összeg (ha None, akkor teljes összeg)
**kwargs: További paraméterek
Returns:
Szótár a visszatérítés részleteivel
Raises:
PaymentGatewayError: Ha a visszatérítés sikertelen
"""
pass
class BaseInvoicingService(ABC):
"""
Absztrakt osztály számlázási szolgáltatások számára.
Minden számlázási rendszer (számlázz.hu, NAV Online Számla, stb.) implementálja
ezt az interfészt a számlák egységes kezeléséhez.
"""
@abstractmethod
async def issue_invoice(
self,
issuer_id: int,
customer_data: Dict[str, Any],
items: list[Dict[str, Any]],
**kwargs
) -> Dict[str, Any]:
"""
Szála kiállítása.
Args:
issuer_id: A számlakiállító (Issuer) azonosítója
customer_data: Ügyfél adatok (név, cím, adószám, stb.)
items: Számla tételek listája
**kwargs: További paraméterek
Returns:
Szótár a számla részleteivel (pl. invoice_number, issue_date, total_amount)
Raises:
InvoicingError: Ha a számla kiállítása sikertelen
"""
pass
@abstractmethod
async def get_invoice_status(
self,
invoice_id: str,
**kwargs
) -> Dict[str, Any]:
"""
Számla státuszának lekérdezése.
Args:
invoice_id: A számla azonosítója
**kwargs: További paraméterek
Returns:
Szótár a számla státuszával és további adatokkal
Raises:
InvoicingError: Ha a státusz lekérdezése sikertelen
"""
pass
@abstractmethod
async def cancel_invoice(
self,
invoice_id: str,
reason: Optional[str] = None,
**kwargs
) -> Dict[str, Any]:
"""
Számla érvénytelenítése.
Args:
invoice_id: A számla azonosítója
reason: Érvénytelenítés oka
**kwargs: További paraméterek
Returns:
Szótár az érvénytelenítés eredményével
Raises:
InvoicingError: Ha az érvénytelenítés sikertelen
"""
pass
# Egyéni kivételek a finanszírozási szolgáltatásokhoz
class FinancialServiceError(Exception):
"""Alap kivétel az összes finanszírozási szolgáltatási hibához."""
pass
class PaymentGatewayError(FinancialServiceError):
"""Kivétel fizetési átjáró hibákhoz."""
pass
class InvoicingError(FinancialServiceError):
"""Kivétel számlázási hibákhoz."""
pass
class InsufficientFundsError(FinancialServiceError):
"""Kivétel elégtelen egyenleg esetén."""
pass

View File

@@ -0,0 +1,449 @@
"""
Financial Orchestrator - Unit of Work mintával a pénzügyi tranzakciók atomi kezeléséhez.
Ez a szolgáltatás koordinálja a fizetési folyamatokat, a számlázást és a pénztárca
műveleteket egyetlen atomi tranzakcióban (Unit of Work minta).
Kulcsfontosságú funkciók:
1. Vetésforgó (select_issuer) - kiválasztja a megfelelő számlakiállítót
2. Unit of Work - minden adatbázis művelet egy tranzakcióban
3. Hibatűrés - rollback hiba esetén
"""
import logging
from decimal import Decimal
from typing import Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, and_
from app.models.audit import FinancialLedger, WalletType, LedgerStatus, LedgerEntryType
from app.models.identity import Wallet
from app.models.finance import Issuer, IssuerType
from app.services.financial_interfaces import (
BasePaymentGateway, BaseInvoicingService,
PaymentGatewayError, InvoicingError, InsufficientFundsError
)
logger = logging.getLogger(__name__)
class FinancialOrchestrator:
"""
Pénzügyi tranzakciók koordinálója Unit of Work mintával.
Ez az osztály felelős a következőkért:
- Számlakiállító kiválasztása (vetésforgó logika)
- FinancialLedger bejegyzés létrehozása
- Pénztárca egyenleg frissítése
- Tranzakció atomi végrehajtása (commit/rollback)
"""
def __init__(
self,
payment_gateway: Optional[BasePaymentGateway] = None,
invoicing_service: Optional[BaseInvoicingService] = None
):
"""
Inicializálás opcionális külső szolgáltatásokkal.
Args:
payment_gateway: Fizetési átjáró implementáció (pl. Stripe)
invoicing_service: Számlázási szolgáltatás implementáció
"""
self.payment_gateway = payment_gateway
self.invoicing_service = invoicing_service
async def select_issuer(
self,
db: AsyncSession,
amount: Decimal,
is_company: bool = False
) -> Issuer:
"""
Vetésforgó logika: kiválasztja a megfelelő számlakiállítót.
Logika:
1. Keressen egy aktív 'EV' típusú Issuert
2. Ha az `current_revenue + amount < revenue_limit` ÉS a vevő nem cég
(`is_company == False`), térjen vissza az EV-vel
3. Minden más esetben térjen vissza az aktív 'KFT' típusú Issuerrel
Args:
db: Adatbázis munkamenet
amount: A tranzakció összege
is_company: A vevő cég-e (True esetén nem választható EV)
Returns:
A kiválasztott Issuer objektum
Raises:
ValueError: Ha nincs aktív számlakiállító
"""
# 1. EV típusú aktív számlakiállító keresése
ev_query = select(Issuer).where(
and_(
Issuer.type == IssuerType.EV,
Issuer.is_active == True
)
).order_by(Issuer.id)
ev_result = await db.execute(ev_query)
ev_issuer_obj = ev_result.scalars().first()
logger.debug(f"EV számlakiállító keresés: talált={ev_issuer_obj is not None}, is_company={is_company}")
# 2. Ellenőrizzük, hogy az EV használható-e
if ev_issuer_obj and not is_company:
# Számoljuk ki az új bevételt
new_revenue = ev_issuer_obj.current_revenue + amount
logger.debug(f"EV ellenőrzés: current_revenue={ev_issuer_obj.current_revenue}, amount={amount}, new_revenue={new_revenue}, limit={ev_issuer_obj.revenue_limit}")
if new_revenue < ev_issuer_obj.revenue_limit:
logger.info(f"EV számlakiállító kiválasztva: {ev_issuer_obj.id} "
f"(új bevétel: {new_revenue}, limit: {ev_issuer_obj.revenue_limit})")
return ev_issuer_obj
else:
logger.debug(f"EV limit túllépve: {new_revenue} >= {ev_issuer_obj.revenue_limit}")
# 3. KFT típusú aktív számlakiállító keresése
kft_query = select(Issuer).where(
and_(
Issuer.type == IssuerType.KFT,
Issuer.is_active == True
)
).order_by(Issuer.id)
kft_result = await db.execute(kft_query)
kft_issuer_obj = kft_result.scalars().first()
logger.debug(f"KFT számlakiállító keresés: talált={kft_issuer_obj is not None}")
if kft_issuer_obj:
logger.info(f"KFT számlakiállító kiválasztva: {kft_issuer_obj.id}")
return kft_issuer_obj
# 4. Ha egyik sem található, hiba
raise ValueError("Nincs aktív számlakiállító (sem EV, sem KFT)")
async def process_payment(
self,
db: AsyncSession,
user_id: int,
amount: Decimal,
wallet_type: WalletType,
description: str = "",
metadata: Optional[Dict[str, Any]] = None,
is_company: bool = False
) -> Dict[str, Any]:
"""
Fő fizetési folyamat Unit of Work mintával.
A folyamat egyetlen nagy try...except...finally blokkban fut:
1. Kiválasztja a számlakiállítót (vetésforgó)
2. Létrehoz egy bejegyzést a FinancialLedger-ben (PENDING státusszal)
3. Frissíti a megfelelő Wallet egyenlegét
4. Csak a legvégén hív egyetlen db.commit()-ot
5. Hiba esetén KÖTELEZŐ a db.rollback()
Args:
db: Adatbázis munkamenet
user_id: A felhasználó azonosítója
amount: A fizetendő összeg (pozitív)
wallet_type: A cél pénztárca típusa
description: Tranzakció leírása
metadata: Egyéni metaadatok
is_company: A felhasználó cég-e
Returns:
Szótár a tranzakció részleteivel
Raises:
InsufficientFundsError: Ha nincs elég egyenleg
PaymentGatewayError: Ha a fizetési átjáró hibát jelez
ValueError: Ha érvénytelen paraméterek
"""
if amount <= 0:
raise ValueError("Az összegnek pozitívnak kell lennie")
# Unit of Work: egyetlen tranzakció
try:
logger.info(f"Payment process indítása: user={user_id}, amount={amount}, "
f"wallet_type={wallet_type}, is_company={is_company}")
# 1. Számlakiállító kiválasztása
issuer = await self.select_issuer(db, amount, is_company)
logger.info(f"Személyi számlakiállító kiválasztva: {issuer.id} ({issuer.type})")
# 2. FinancialLedger bejegyzés létrehozása (PENDING státusszal)
ledger_entry = FinancialLedger(
user_id=user_id,
amount=float(amount), # Convert Decimal to float for Numeric field
wallet_type=wallet_type,
status=LedgerStatus.PENDING,
issuer_id=issuer.id,
entry_type=LedgerEntryType.DEBIT, # Payment is a DEBIT
currency="HUF", # Default currency
transaction_type=description or "Payment via FinancialOrchestrator",
details=metadata or {} # Store metadata in details JSON field
)
db.add(ledger_entry)
await db.flush() # Megkapjuk az ID-t, de még nincs commit
logger.info(f"FinancialLedger bejegyzés létrehozva: {ledger_entry.id}")
# 3. Pénztárca egyenleg frissítése
# Először lekérjük a pénztárcát zárolással (minden usernek csak egy walletje van)
wallet_query = select(Wallet).where(
Wallet.user_id == user_id
).with_for_update() # Sorzárolás a konkurrens hozzáférés megelőzésére
wallet_result = await db.execute(wallet_query)
wallet = wallet_result.scalar_one_or_none()
if not wallet:
raise ValueError(f"Nincs pénztárca a user {user_id} számára")
# Ellenőrizzük az egyenleget (ha kivételről van szó)
# Megjegyzés: A valós implementációban itt ellenőriznénk, hogy van-e elég egyenleg
# de a specifikáció szerint csak frissítjük az egyenleget
# A Wallet modellben nincs 'balance' mező, hanem külön mezők vannak a különböző credit típusokhoz
# Frissítjük a megfelelő credit mezőt a wallet_type alapján
# MEGJEGYZÉS: Payment (DEBIT) csökkenti a pénztárca egyenlegét!
update_values = {}
current_balance = Decimal('0')
if wallet_type == WalletType.EARNED:
current_balance = Decimal(str(wallet.earned_credits))
new_balance = current_balance - amount # DEBIT csökkenti az egyenleget
update_values['earned_credits'] = float(new_balance)
elif wallet_type == WalletType.PURCHASED:
current_balance = Decimal(str(wallet.purchased_credits))
new_balance = current_balance - amount # DEBIT csökkenti az egyenleget
update_values['purchased_credits'] = float(new_balance)
elif wallet_type == WalletType.SERVICE_COINS:
current_balance = Decimal(str(wallet.service_coins))
new_balance = current_balance - amount # DEBIT csökkenti az egyenleget
update_values['service_coins'] = float(new_balance)
elif wallet_type == WalletType.VOUCHER:
# VOUCHER típusnál nincs dedikált mező a Wallet modellben
# Kezeljük mint SERVICE_COINS vagy dobjunk hibát
current_balance = Decimal(str(wallet.service_coins))
new_balance = current_balance - amount # DEBIT csökkenti az egyenleget
update_values['service_coins'] = float(new_balance)
logger.warning(f"VOUCHER wallet_type használva, SERVICE_COINS frissítve")
else:
raise ValueError(f"Ismeretlen wallet_type: {wallet_type}")
# Frissítjük a pénztárcát
await db.execute(
update(Wallet)
.where(Wallet.id == wallet.id)
.values(**update_values)
)
logger.info(f"Pénztárca frissítve: {wallet.id}, wallet_type={wallet_type}, új egyenleg: {new_balance} (korábbi: {current_balance})")
# 4. FinancialLedger státusz frissítése SUCCESS-re
ledger_entry.status = LedgerStatus.SUCCESS
# 5. Számlakiállító bevételének frissítése
issuer.current_revenue += amount
db.add(issuer)
# 6. Külső szolgáltatások meghívása (ha vannak)
external_results = {}
if self.payment_gateway:
try:
payment_result = await self.payment_gateway.create_intent(
amount=amount,
currency="HUF",
metadata={
"ledger_id": ledger_entry.id,
"user_id": user_id,
"issuer_id": issuer.id,
**(metadata or {})
}
)
external_results["payment"] = payment_result
logger.info(f"Fizetési szándék létrehozva: {payment_result.get('id')}")
except PaymentGatewayError as e:
logger.error(f"Fizetési átjáró hiba: {e}")
# Döntés: tovább dobjuk a hibát, ami rollback-et okoz
raise
if self.invoicing_service:
try:
# Ügyfél adatok gyűjtése (egyszerűsített)
customer_data = {
"user_id": user_id,
"amount": float(amount),
"description": description
}
invoice_result = await self.invoicing_service.issue_invoice(
issuer_id=issuer.id,
customer_data=customer_data,
items=[{
"description": description or "Szolgáltatás díja",
"quantity": 1,
"unit_price": float(amount),
"vat_rate": 27.0 # ÁFA kulcs
}]
)
external_results["invoice"] = invoice_result
logger.info(f"Szála kiállítva: {invoice_result.get('invoice_number')}")
except InvoicingError as e:
logger.error(f"Számlázási hiba: {e}")
# Döntés: tovább dobjuk a hibát, ami rollback-et okoz
raise
# 7. COMMIT - minden művelet sikeres, atomi mentés
await db.commit()
logger.info(f"Tranzakció sikeresen commitálva: ledger_id={ledger_entry.id}")
# Visszatérési érték
return {
"success": True,
"ledger_id": ledger_entry.id,
"issuer_id": issuer.id,
"issuer_type": issuer.type,
"wallet_id": wallet.id,
"new_balance": new_balance,
"external_results": external_results,
"message": "Payment processed successfully"
}
except Exception as e:
# 8. ROLLBACK - bármilyen hiba esetén
logger.error(f"Hiba a tranzakcióban: {e}", exc_info=True)
await db.rollback()
# Speciális hibák újradobása
if isinstance(e, (InsufficientFundsError, PaymentGatewayError, InvoicingError)):
raise
# Általános hiba
raise FinancialOrchestratorError(f"Payment processing failed: {e}") from e
finally:
# 9. További takarítás (ha szükséges)
# Jelenleg nincs extra takarítási logika
pass
async def refund_payment(
self,
db: AsyncSession,
ledger_id: int,
reason: str = ""
) -> Dict[str, Any]:
"""
Visszatérítés folyamata Unit of Work mintával.
Ez a metódus visszafordítja egy korábbi tranzakciót:
1. Megkeresi az eredeti FinancialLedger bejegyzést
2. Létrehoz egy negatív összegű bejegyzést (REFUND státusszal)
3. Visszaállítja a pénztárca egyenlegét
4. Visszaállítja a számlakiállító bevételét
Args:
db: Adatbázis munkamenet
ledger_id: Az eredeti FinancialLedger bejegyzés azonosítója
reason: Visszatérítés oka
Returns:
Szótár a visszatérítés részleteivel
"""
try:
logger.info(f"Visszatérítés indítása: ledger_id={ledger_id}")
# 1. Eredeti bejegyzés lekérdezése
original_query = select(FinancialLedger).where(
FinancialLedger.id == ledger_id
).with_for_update()
original_result = await db.execute(original_query)
original_entry = original_result.scalar_one_or_none()
if not original_entry:
raise ValueError(f"Nincs FinancialLedger bejegyzés a következő ID-val: {ledger_id}")
if original_entry.status != LedgerStatus.SUCCESS:
raise ValueError(f"Csak SUCCESS státuszú bejegyzések téríthetők vissza. "
f"Jelenlegi státusz: {original_entry.status}")
# 2. Visszatérítési bejegyzés létrehozása
refund_entry = FinancialLedger(
user_id=original_entry.user_id,
amount=-original_entry.amount, # Negatív összeg
wallet_type=original_entry.wallet_type,
status=LedgerStatus.REFUND,
issuer_id=original_entry.issuer_id,
description=f"Visszatérítés: {reason}" if reason else "Visszatérítés",
metadata={
"original_ledger_id": ledger_id,
"reason": reason,
"refund_type": "full"
}
)
db.add(refund_entry)
await db.flush()
# 3. Pénztárca egyenleg visszaállítása
wallet_query = select(Wallet).where(
and_(
Wallet.user_id == original_entry.user_id,
Wallet.wallet_type == original_entry.wallet_type
)
).with_for_update()
wallet_result = await db.execute(wallet_query)
wallet = wallet_result.scalar_one_or_none()
if wallet:
new_balance = wallet.balance - original_entry.amount
await db.execute(
update(Wallet)
.where(Wallet.id == wallet.id)
.values(balance=new_balance)
)
# 4. Számlakiállító bevételének csökkentése
issuer_query = select(Issuer).where(Issuer.id == original_entry.issuer_id)
issuer_result = await db.execute(issuer_query)
issuer = issuer_result.scalar_one()
issuer.current_revenue -= original_entry.amount
db.add(issuer)
# 5. Eredeti bejegyzés státuszának frissítése
original_entry.status = LedgerStatus.REFUNDED
original_entry.metadata = {
**(original_entry.metadata or {}),
"refund_ledger_id": refund_entry.id,
"refund_reason": reason
}
# 6. COMMIT
await db.commit()
logger.info(f"Visszatérítés sikeres: refund_ledger_id={refund_entry.id}")
return {
"success": True,
"refund_ledger_id": refund_entry.id,
"original_ledger_id": ledger_id,
"amount_refunded": original_entry.amount,
"message": "Refund processed successfully"
}
except Exception as e:
logger.error(f"Hiba a visszatérítésben: {e}", exc_info=True)
await db.rollback()
raise FinancialOrchestratorError(f"Refund processing failed: {e}") from e
class FinancialOrchestratorError(Exception):
"""Kivétel a FinancialOrchestrator hibáinak kezelés"""

View File

@@ -28,8 +28,8 @@ class GeoService:
query = text("""
SELECT DISTINCT s.name
FROM data.geo_streets s
JOIN data.geo_postal_codes p ON s.postal_code_id = p.id
FROM system.geo_streets s
JOIN system.geo_postal_codes p ON s.postal_code_id = p.id
WHERE p.zip_code = :zip AND s.name ILIKE :q
ORDER BY s.name ASC LIMIT :limit
""")
@@ -76,7 +76,7 @@ class GeoService:
# 2. Irányítószám és Város (Auto-learning / Upsert)
zip_id_query = text("""
INSERT INTO data.geo_postal_codes (zip_code, city, country_code)
INSERT INTO system.geo_postal_codes (zip_code, city, country_code)
VALUES (:z, :c, :cc)
ON CONFLICT (country_code, zip_code, city) DO UPDATE SET city = EXCLUDED.city
RETURNING id
@@ -86,13 +86,13 @@ class GeoService:
# 3. Utca szótár frissítése
await db.execute(text("""
INSERT INTO data.geo_streets (postal_code_id, name) VALUES (:zid, :n)
INSERT INTO system.geo_streets (postal_code_id, name) VALUES (:zid, :n)
ON CONFLICT (postal_code_id, name) DO NOTHING
"""), {"zid": zip_id, "n": street_name})
# 4. Közterület típus (út, utca, köz...)
await db.execute(text("""
INSERT INTO data.geo_street_types (name) VALUES (:n)
INSERT INTO system.geo_street_types (name) VALUES (:n)
ON CONFLICT (name) DO NOTHING
"""), {"n": street_type.lower()})
@@ -113,7 +113,7 @@ class GeoService:
# 6. Központi Address rekord rögzítése vagy lekérése
address_query = text("""
INSERT INTO data.addresses (
INSERT INTO system.addresses (
postal_code_id, street_name, street_type, house_number,
stairwell, floor, door, parcel_id, full_address_text
)
@@ -135,7 +135,7 @@ class GeoService:
# 7. Biztonsági keresés: Ha létezett a rekord, de nem kaptunk ID-t a RETURNING-gal
if not addr_id:
lookup_query = text("""
SELECT id FROM data.addresses
SELECT id FROM system.addresses
WHERE postal_code_id = :zid
AND street_name = :sn
AND street_type = :st

View File

@@ -0,0 +1,185 @@
# /opt/docker/dev/service_finder/backend/app/services/logbook_service.py
"""
Logbook Service - GPS, OBDII és előfizetési szűrő kezelése.
"""
import logging
from typing import Optional, Tuple, Any
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.asset import VehicleLogbook
from app.models.gamification import UserStats
from app.models.identity import User
from app.models.system import SystemParameter
logger = logging.getLogger("Logbook-Service-2.0")
class LogbookService:
"""
Útnyilvántartás kezelése GPS koordinátákkal, OBDII adatokkal és előfizetési szintű jogosultságokkal.
"""
@staticmethod
async def get_system_parameter(db: AsyncSession, key: str, default: Any = None) -> Any:
"""
Lekéri a rendszerparamétert a system.system_parameters táblából.
Elsőként a global scope-ot keresi (scope_level='global', scope_id=NULL).
Ha nem talál, visszaadja a default értéket.
"""
stmt = select(SystemParameter).where(
SystemParameter.key == key,
SystemParameter.scope_level == 'global',
SystemParameter.scope_id.is_(None),
SystemParameter.is_active == True
).order_by(SystemParameter.updated_at.desc())
result = await db.execute(stmt)
param = result.scalar_one_or_none()
if param and 'value' in param.value:
return param.value['value']
return default
@staticmethod
async def get_user_rank(db: AsyncSession, user_id: int) -> int:
"""
Lekérdezi a felhasználó aktuális rankját (current_level) a UserStats táblából.
Ha nincs rekord, alapértelmezett 0 (ingyenes szint).
"""
stmt = select(UserStats.current_level).where(UserStats.user_id == user_id)
result = await db.execute(stmt)
rank = result.scalar_one_or_none()
return rank if rank is not None else 0
@staticmethod
async def check_subscription_guard(
db: AsyncSession,
user_id: int,
wants_gps: bool = False,
wants_obd: bool = False
) -> Tuple[bool, str]:
"""
Ellenőrzi, hogy a felhasználó előfizetési szintje engedélyezi-e a GPS/OBDII adatok rögzítését.
Szabályok:
- Rank >= LOGBOOK_GPS_MIN_RANK (alapértelmezett 50): engedélyezett a GPS távolság és koordináták
- Rank >= 90 (VIP/Admin): minden engedélyezett (GPS, OBDII, gyorsulás)
- Rank < LOGBOOK_GPS_MIN_RANK: csak manuális distance_km és trip_type rögzíthető
Visszatérés: (allowed: bool, message: str)
"""
rank = await LogbookService.get_user_rank(db, user_id)
gps_min_rank = await LogbookService.get_system_parameter(db, 'LOGBOOK_GPS_MIN_RANK', 50)
vip_min_rank = 90 # Fix VIP küszöb
if rank >= vip_min_rank:
return True, "VIP/Admin szint: minden adat rögzíthető"
if rank >= gps_min_rank:
if wants_gps or wants_obd:
return True, f"PREMIUM szint (rank {rank} >= {gps_min_rank}): GPS és OBDII adatok rögzíthetők"
return True, "PREMIUM szint"
# Ingyenes felhasználó
if wants_gps or wants_obd:
return False, f"Ingyenes felhasználók (rank {rank} < {gps_min_rank}) nem rögzíthetnek GPS koordinátákat vagy OBDII adatokat. Csak manuális distance_km és trip_type engedélyezett."
return True, "Ingyenes szint: csak manuális adatok"
@staticmethod
async def create_logbook_entry(
db: AsyncSession,
asset_id: str,
driver_id: int,
trip_type: str,
start_mileage: int,
end_mileage: Optional[int] = None,
distance_km: Optional[float] = None,
start_lat: Optional[float] = None,
start_lng: Optional[float] = None,
end_lat: Optional[float] = None,
end_lng: Optional[float] = None,
gps_calculated_distance: Optional[float] = None,
obd_verified: bool = False,
max_acceleration: Optional[float] = None,
average_speed: Optional[float] = None,
) -> VehicleLogbook:
"""
Új útnyilvántartás bejegyzés létrehozása előfizetési szűrővel.
Automatikusan ellenőrzi, hogy a felhasználó rankja engedélyezi-e a GPS/OBDII mezők kitöltését.
Ha nem, a GPS és OBDII mezők null-ra állnak, és csak a manuális distance_km marad.
"""
# Ellenőrizzük a jogosultságot
wants_gps = any([start_lat, start_lng, end_lat, end_lng, gps_calculated_distance])
wants_obd = obd_verified or max_acceleration is not None or average_speed is not None
allowed, message = await LogbookService.check_subscription_guard(
db, driver_id, wants_gps, wants_obd
)
if not allowed:
# Ha nem engedélyezett, nullázzuk a tiltott mezőket
logger.warning(f"User {driver_id} attempted to log GPS/OBDII without permission. {message}")
start_lat = start_lng = end_lat = end_lng = gps_calculated_distance = None
obd_verified = False
max_acceleration = average_speed = None
# Új bejegyzés létrehozása
new_entry = VehicleLogbook(
asset_id=asset_id,
driver_id=driver_id,
trip_type=trip_type,
start_mileage=start_mileage,
end_mileage=end_mileage,
distance_km=distance_km,
start_lat=start_lat,
start_lng=start_lng,
end_lat=end_lat,
end_lng=end_lng,
gps_calculated_distance=gps_calculated_distance,
obd_verified=obd_verified,
max_acceleration=max_acceleration,
average_speed=average_speed,
)
db.add(new_entry)
await db.commit()
await db.refresh(new_entry)
logger.info(f"Logbook entry created for asset {asset_id}, driver {driver_id}, trip_type {trip_type}")
return new_entry
@staticmethod
async def calculate_official_distance(
start_coords: Tuple[float, float],
end_coords: Tuple[float, float]
) -> Optional[float]:
"""
TODO: OSRM/Google Maps API hívással számolja ki a legrövidebb útvonal távolságát.
Egyelőre placeholder, később implementálandó.
Visszatérés: távolság kilométerben (float) vagy None, ha nem sikerült.
"""
# TODO: Integrálni OSRM vagy Google Maps Distance Matrix API-t
# Példa: https://project-osrm.org/docs/v5.24.0/api/#route-service
# Jelenleg egyszerű haversine formula alapján számolunk
from math import radians, sin, cos, sqrt, atan2
lat1, lon1 = start_coords
lat2, lon2 = end_coords
R = 6371.0 # Föld sugara km-ben
lat1_rad = radians(lat1)
lon1_rad = radians(lon1)
lat2_rad = radians(lat2)
lon2_rad = radians(lon2)
dlon = lon2_rad - lon1_rad
dlat = lat2_rad - lat1_rad
a = sin(dlat / 2)**2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2)**2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
distance_km = R * c
return round(distance_km, 2)

View File

@@ -0,0 +1,269 @@
# /opt/docker/dev/service_finder/backend/app/services/marketplace_service.py
"""
Marketplace Service Verifikált Szerviz Értékelések (Social 3) logikája.
"""
import logging
import uuid
import asyncio
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Tuple
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError
from app.models.social import ServiceReview
from app.models.service import ServiceProfile
from app.models.identity import User
from app.models.audit import FinancialLedger
from app.models.system import SystemParameter
from app.schemas.social import ServiceReviewCreate, ServiceReviewResponse
from app.services.system_service import get_system_parameter
logger = logging.getLogger(__name__)
async def create_verified_review(
db: AsyncSession,
service_id: int,
user_id: int,
transaction_id: uuid.UUID,
review_data: ServiceReviewCreate,
) -> ServiceReviewResponse:
"""
Verifikált szerviz értékelés létrehozása.
Csak igazolt pénzügyi tranzakció után, időablakon belül, egy tranzakcióra egyszer.
Args:
db: AsyncSession
service_id: A szerviz ID (service_profiles.id)
user_id: A felhasználó ID (users.id)
transaction_id: A pénzügyi tranzakció UUID (financial_ledger.transaction_id)
review_data: Értékelési adatok (ratings, comment)
Returns:
ServiceReviewResponse
Raises:
ValueError: Ha a validáció sikertelen.
IntegrityError: Ha a tranzakció már értékelve van.
"""
# 1. Ellenőrzés: Létezike a szerviz?
service = await db.get(ServiceProfile, service_id)
if not service:
raise ValueError(f"Service {service_id} not found")
# 2. Ellenőrzés: Létezike a felhasználó?
user = await db.get(User, user_id)
if not user:
raise ValueError(f"User {user_id} not found")
# 3. Ellenőrzés: A tranzakció létezik és a felhasználóhoz tartozik?
stmt = select(FinancialLedger).where(
FinancialLedger.transaction_id == transaction_id,
FinancialLedger.user_id == user_id
)
result = await db.execute(stmt)
transaction = result.scalar_one_or_none()
if not transaction:
raise ValueError(f"Transaction {transaction_id} not found or does not belong to user {user_id}")
# 4. Ellenőrzés: A tranzakció időpontja a REVIEW_WINDOW_DAYSon belül van?
window_days = await get_system_parameter(db, "REVIEW_WINDOW_DAYS", default=30)
window_limit = datetime.now() - timedelta(days=window_days)
if transaction.created_at < window_limit:
raise ValueError(f"Transaction is older than {window_days} days, review window expired")
# 5. Ellenőrzés: Már létezike értékelés ehhez a tranzakcióhoz?
existing_review = await db.execute(
select(ServiceReview).where(ServiceReview.transaction_id == transaction_id)
)
if existing_review.scalar_one_or_none():
raise IntegrityError(f"Transaction {transaction_id} already has a review")
# 6. Értékelési dimenziók validálása (110)
ratings = [
review_data.price_rating,
review_data.quality_rating,
review_data.time_rating,
review_data.communication_rating
]
for rating in ratings:
if not (1 <= rating <= 10):
raise ValueError("All ratings must be between 1 and 10")
# 7. ServiceReview létrehozása
review = ServiceReview(
service_id=service_id,
user_id=user_id,
transaction_id=transaction_id,
price_rating=review_data.price_rating,
quality_rating=review_data.quality_rating,
time_rating=review_data.time_rating,
communication_rating=review_data.communication_rating,
comment=review_data.comment,
is_verified=True
)
db.add(review)
await db.commit()
await db.refresh(review)
# 8. Háttéraggregátor indítása (aszinkron)
asyncio.create_task(update_service_rating_aggregates(db, service_id))
logger.info(f"Verified review created: id={review.id}, service={service_id}, user={user_id}")
return ServiceReviewResponse.from_orm(review)
async def update_service_rating_aggregates(db: AsyncSession, service_id: int) -> None:
"""
Frissíti a szerviz aggregált értékelési adatait (service_profiles táblában).
Ez a függvény háttérben futhat (pl. Celery vagy asyncio task).
"""
# Összes verifikált értékelés lekérdezése a szervizhez
stmt = select(
func.count(ServiceReview.id).label("count"),
func.avg(ServiceReview.price_rating).label("price_avg"),
func.avg(ServiceReview.quality_rating).label("quality_avg"),
func.avg(ServiceReview.time_rating).label("time_avg"),
func.avg(ServiceReview.communication_rating).label("communication_avg"),
func.max(ServiceReview.created_at).label("last_review_at")
).where(
and_(
ServiceReview.service_id == service_id,
ServiceReview.is_verified == True
)
)
result = await db.execute(stmt)
row = result.fetchone()
if not row or row.count == 0:
# Nincs értékelés, alapértékek
price_avg = quality_avg = time_avg = communication_avg = None
count = 0
last_review_at = None
else:
count = row.count
price_avg = float(row.price_avg) if row.price_avg else None
quality_avg = float(row.quality_avg) if row.quality_avg else None
time_avg = float(row.time_avg) if row.time_avg else None
communication_avg = float(row.communication_avg) if row.communication_avg else None
last_review_at = row.last_review_at
# Trustscore súlyozás: a felhasználók trustscorejának átlaga
trust_stmt = select(func.avg(User.trust_score)).join(
ServiceReview, ServiceReview.user_id == User.id
).where(
and_(
ServiceReview.service_id == service_id,
ServiceReview.is_verified == True
)
)
trust_result = await db.execute(trust_stmt)
avg_trust = trust_result.scalar() or 50.0 # alapérték 50
# Trustscore befolyási tényező
trust_factor = await get_system_parameter(db, "TRUST_SCORE_INFLUENCE_FACTOR", default=1.0)
trust_weight = 1.0 + (avg_trust / 100.0) * trust_factor
# Súlyozott összpontszám számítása
weights = await get_system_parameter(db, "REVIEW_RATING_WEIGHTS", default={
"price": 0.25,
"quality": 0.35,
"time": 0.20,
"communication": 0.20
})
weighted_score = 0.0
if price_avg:
weighted_score += price_avg * weights.get("price", 0.25)
if quality_avg:
weighted_score += quality_avg * weights.get("quality", 0.35)
if time_avg:
weighted_score += time_avg * weights.get("time", 0.20)
if communication_avg:
weighted_score += communication_avg * weights.get("communication", 0.20)
weighted_score *= trust_weight
# ServiceProfile frissítése
service = await db.get(ServiceProfile, service_id)
if service:
service.rating_verified_count = count
service.rating_price_avg = price_avg
service.rating_quality_avg = quality_avg
service.rating_time_avg = time_avg
service.rating_communication_avg = communication_avg
service.rating_overall = weighted_score
service.last_review_at = last_review_at
await db.commit()
logger.debug(f"Updated rating aggregates for service {service_id}: count={count}, overall={weighted_score:.2f}")
async def get_service_reviews(
db: AsyncSession,
service_id: int,
skip: int = 0,
limit: int = 20,
verified_only: bool = True
) -> Tuple[List[ServiceReviewResponse], int]:
"""
Szerviz értékeléseinek lapozható listázása.
Args:
db: AsyncSession
service_id: A szerviz ID
skip: Lapozási offset
limit: Maximális darabszám
verified_only: Csak verifikált értékelések
Returns:
(reviews, total_count)
"""
conditions = [ServiceReview.service_id == service_id]
if verified_only:
conditions.append(ServiceReview.is_verified == True)
# Összes darabszám
count_stmt = select(func.count(ServiceReview.id)).where(*conditions)
total_result = await db.execute(count_stmt)
total = total_result.scalar()
# Lapozott lekérdezés
stmt = select(ServiceReview).where(*conditions).order_by(
ServiceReview.created_at.desc()
).offset(skip).limit(limit)
result = await db.execute(stmt)
reviews = result.scalars().all()
return [ServiceReviewResponse.from_orm(r) for r in reviews], total
async def can_user_review_service(
db: AsyncSession,
user_id: int,
service_id: int
) -> Tuple[bool, Optional[str]]:
"""
Ellenőrzi, hogy a felhasználó értékelhetie a szervizt.
Returns:
(can_review, reason)
"""
# 1. Vane már értékelése a szervizre?
existing_stmt = select(ServiceReview).where(
ServiceReview.user_id == user_id,
ServiceReview.service_id == service_id
)
existing = await db.execute(existing_stmt)
if existing.scalar_one_or_none():
return False, "User already reviewed this service"
# 2. Vane a felhasználónak tranzakciója a szervizzel?
# Megjegyzés: A tranzakciószerviz kapcsolat jelenleg nincs tárolva.
# Ehhez a FinancialLedgerben kellene egy service_id mező, vagy
# egy kapcsolótábla. Most csak annyit ellenőrzünk, hogy vane bármilyen
# tranzakció a felhasználónak, ami még nem értékelt.
# TODO: Később pontosítani a tranzakciószerviz kapcsolatot.
return True, None

View File

@@ -0,0 +1,213 @@
"""
Smart Odometer Service - Adminisztrátor által paraméterezhető kilométeróra becslés.
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.
"""
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.vehicle_definitions 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

View File

@@ -0,0 +1,147 @@
# /opt/docker/dev/service_finder/backend/app/services/system_service.py
"""
Hierarchikus System Parameters szolgáltatás.
A rendszerparaméterek prioritásos felülbírálást támogatnak: User > Region > Country > Global.
"""
import logging
from typing import Optional, Any, Dict
from sqlalchemy import select, func # HOZZÁADVA: func a NOW() híváshoz
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.system import SystemParameter, ParameterScope
logger = logging.getLogger(__name__)
class SystemService:
"""
Rendszerparaméterek kezelése hierarchikus scope-okkal.
"""
async def get_scoped_parameter(
self,
db: AsyncSession,
key: str,
user_id: Optional[str] = None,
region_id: Optional[str] = None,
country_code: Optional[str] = None,
default: Any = None,
) -> Any:
"""
Lekéri a paraméter értékét a következő prioritási sorrendben:
1. USER scope (ha user_id megadva)
2. REGION scope (ha region_id megadva)
3. COUNTRY scope (ha country_code megadva)
4. GLOBAL scope
Ha egy scope-ban nem található a paraméter, a következő scope-ot próbálja.
Visszaadja a paraméter JSON értékét (általában dict), vagy a default értéket.
:param db: Adatbázis munkamenet
:param key: A paraméter kulcsa
:param user_id: Felhasználó azonosítója (opcionális)
:param region_id: Régió azonosítója (opcionális)
:param country_code: Országkód (pl. 'HU', 'GB') (opcionális)
:param default: Alapértelmezett érték, ha a paraméter nem található
:return: A paraméter értéke (általában dict) vagy default
"""
# Prioritási sorrend: USER -> REGION -> COUNTRY -> GLOBAL
scopes = []
if user_id:
scopes.append((ParameterScope.USER, str(user_id)))
if region_id:
scopes.append((ParameterScope.REGION, str(region_id)))
if country_code:
scopes.append((ParameterScope.COUNTRY, str(country_code)))
scopes.append((ParameterScope.GLOBAL, None))
for scope_level, scope_id in scopes:
stmt = select(SystemParameter).where(
SystemParameter.key == key,
SystemParameter.scope_level == scope_level,
SystemParameter.is_active == True,
)
if scope_id is not None:
stmt = stmt.where(SystemParameter.scope_id == scope_id)
else:
stmt = stmt.where(SystemParameter.scope_id.is_(None))
result = await db.execute(stmt)
param = result.scalar_one_or_none()
if param is not None:
logger.debug(
f"Paraméter '{key}' található {scope_level.value} scope-ban (scope_id={scope_id})"
)
return param.value
else:
logger.debug(
f"Paraméter '{key}' nem található {scope_level.value} scope-ban (scope_id={scope_id})"
)
logger.info(f"Paraméter '{key}' nem található egyetlen scope-ban sem, default értéket használunk")
return default
async def set_scoped_parameter(
self,
db: AsyncSession,
key: str,
value: Dict,
scope_level: ParameterScope,
scope_id: Optional[str] = None,
category: str = "general",
description: Optional[str] = None,
last_modified_by: Optional[int] = None,
) -> SystemParameter:
"""
Létrehoz vagy frissít egy rendszerparamétert a megadott scope-ban.
Ha már létezik ugyanazzal a kulccsal, scope_level-lel és scope_id-vel, felülírja.
"""
from sqlalchemy.dialects.postgresql import insert
# UPSERT logika: ON CONFLICT DO UPDATE
insert_stmt = insert(SystemParameter).values(
key=key,
value=value,
scope_level=scope_level,
scope_id=scope_id,
category=category,
description=description,
last_modified_by=last_modified_by,
is_active=True,
)
upsert_stmt = insert_stmt.on_conflict_do_update(
constraint="uix_param_scope",
set_=dict(
value=value,
category=category,
description=description,
last_modified_by=last_modified_by,
updated_at=func.now(),
),
)
await db.execute(upsert_stmt)
await db.commit()
# Visszaolvassuk a létrehozott/frissített rekordot
stmt = select(SystemParameter).where(
SystemParameter.key == key,
SystemParameter.scope_level == scope_level,
SystemParameter.scope_id == scope_id,
)
result = await db.execute(stmt)
param = result.scalar_one()
return param
# --- GLOBÁLIS PÉLDÁNY ÉS SEGÉDFÜGGVÉNYEK ---
# Ezek a fájl legszélén vannak (0-s behúzás), így kívülről importálhatóak!
system_service = SystemService()
async def get_system_parameter(db: AsyncSession, key: str, default: Any = None) -> Any:
"""
Proxy függvény, amit a marketplace_service és más modulok közvetlenül importálnak.
A globális system_service példányt használja.
"""
return await system_service.get_scoped_parameter(db, key, default=default)

View File

@@ -0,0 +1,343 @@
# /opt/docker/dev/service_finder/backend/app/services/trust_engine.py
"""
Gondos Gazda Index (Trust Score) számítási motor.
Dinamikusan betölti a súlyozási paramétereket a SystemParameter rendszerből.
"""
import logging
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.identity import User, UserTrustProfile
from app.models.asset import Vehicle, VehicleOwnership
from app.models.service import Cost
from app.models.system import SystemParameter, ParameterScope
from app.services.system_service import SystemService
logger = logging.getLogger(__name__)
class TrustEngine:
"""
A Gondos Gazda Index számításáért felelős motor.
A számítás három komponensből áll:
1. Maintenance Score - Karbantartási időzítés pontossága
2. Quality Score - Szerviz minősége (ár/érték arány)
3. Preventive Score - Megelőző intézkedések (pl. idő előtti cserék)
Minden komponens súlyozása a SystemParameter rendszerből származik.
"""
def __init__(self):
self.system_service = SystemService()
async def calculate_user_trust(
self,
db: AsyncSession,
user_id: int,
force_recalculate: bool = False
) -> Dict[str, Any]:
"""
Kiszámolja a felhasználó trust score-ját és elmenti a UserTrustProfile táblába.
:param db: Adatbázis munkamenet
:param user_id: A felhasználó azonosítója
:param force_recalculate: Ha True, akkor újraszámolja még akkor is, ha friss
:return: A számított trust adatok szótárban
"""
logger.info(f"Trust számítás indítása user_id={user_id}")
# 1. Ellenőrizzük, hogy szükséges-e újraszámolni
trust_profile = await self._get_or_create_trust_profile(db, user_id)
if not force_recalculate:
# Ha a számítás kevesebb mint 24 órája történt, visszaadjuk a meglévőt
time_threshold = datetime.utcnow() - timedelta(hours=24)
if trust_profile.last_calculated and trust_profile.last_calculated > time_threshold:
logger.debug(f"Trust score már friss (last_calculated={trust_profile.last_calculated}), visszaadjuk")
return self._format_trust_response(trust_profile)
# 2. Lekérjük a súlyozási paramétereket
weights = await self._get_trust_weights(db, user_id)
tolerance_km = await self._get_tolerance_km(db, user_id)
# 3. Számoljuk ki a részpontszámokat
maintenance_score = await self._calculate_maintenance_score(db, user_id, tolerance_km)
quality_score = await self._calculate_quality_score(db, user_id)
preventive_score = await self._calculate_preventive_score(db, user_id)
# 4. Összesített trust score számítása súlyozással
trust_score = int(
(maintenance_score * weights["maintenance"] +
quality_score * weights["quality"] +
preventive_score * weights["preventive"]) * 100
)
# Korlátozzuk 0-100 közé
trust_score = max(0, min(100, trust_score))
# 5. Frissítjük a trust profile-t
trust_profile.trust_score = trust_score
trust_profile.maintenance_score = float(maintenance_score)
trust_profile.quality_score = float(quality_score)
trust_profile.preventive_score = float(preventive_score)
trust_profile.last_calculated = datetime.utcnow()
db.add(trust_profile)
await db.commit()
logger.info(f"Trust számítás kész user_id={user_id}: score={trust_score}")
return {
"trust_score": trust_score,
"maintenance_score": float(maintenance_score),
"quality_score": float(quality_score),
"preventive_score": float(preventive_score),
"weights": weights,
"tolerance_km": tolerance_km,
"last_calculated": trust_profile.last_calculated.isoformat() if trust_profile.last_calculated else None,
}
async def _get_or_create_trust_profile(
self,
db: AsyncSession,
user_id: int
) -> UserTrustProfile:
"""Lekéri vagy létrehozza a felhasználó trust profile-ját."""
stmt = select(UserTrustProfile).where(UserTrustProfile.user_id == user_id)
result = await db.execute(stmt)
profile = result.scalar_one_or_none()
if profile is None:
profile = UserTrustProfile(
user_id=user_id,
trust_score=0,
maintenance_score=0.0,
quality_score=0.0,
preventive_score=0.0,
last_calculated=datetime.utcnow()
)
db.add(profile)
await db.flush()
return profile
async def _get_trust_weights(
self,
db: AsyncSession,
user_id: int
) -> Dict[str, float]:
"""Lekéri a súlyozási paramétereket hierarchikusan."""
# A user region_code-ját és country_code-ját lekérjük a User táblából
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
region_id = user.region_code if user else None
country_code = user.region_code[:2] if user and user.region_code else None # pl. "HU" az első 2 karakter
# Súlyok lekérése
weight_m = await self.system_service.get_scoped_parameter(
db, "TRUST_WEIGHT_MAINTENANCE",
user_id=str(user_id), region_id=region_id, country_code=country_code,
default=0.4
)
weight_q = await self.system_service.get_scoped_parameter(
db, "TRUST_WEIGHT_QUALITY",
user_id=str(user_id), region_id=region_id, country_code=country_code,
default=0.3
)
weight_p = await self.system_service.get_scoped_parameter(
db, "TRUST_WEIGHT_PREVENTIVE",
user_id=str(user_id), region_id=region_id, country_code=country_code,
default=0.3
)
# A JSON értékből kinyerjük a számot (ha dict formátumban van)
if isinstance(weight_m, dict):
weight_m = weight_m.get("value", 0.4)
if isinstance(weight_q, dict):
weight_q = weight_q.get("value", 0.3)
if isinstance(weight_p, dict):
weight_p = weight_p.get("value", 0.3)
# Normalizáljuk, hogy összegük 1 legyen
total = weight_m + weight_q + weight_p
if total > 0:
weight_m /= total
weight_q /= total
weight_p /= total
return {
"maintenance": float(weight_m),
"quality": float(weight_q),
"preventive": float(weight_p)
}
async def _get_tolerance_km(
self,
db: AsyncSession,
user_id: int
) -> int:
"""Lekéri a tolerancia km-t a karbantartási időzítéshez."""
stmt = select(User).where(User.id == user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
region_id = user.region_code if user else None
country_code = user.region_code[:2] if user and user.region_code else None
tolerance = await self.system_service.get_scoped_parameter(
db, "TRUST_MAINTENANCE_TOLERANCE_KM",
user_id=str(user_id), region_id=region_id, country_code=country_code,
default=1000
)
if isinstance(tolerance, dict):
tolerance = tolerance.get("value", 1000)
return int(tolerance)
async def _calculate_maintenance_score(
self,
db: AsyncSession,
user_id: int,
tolerance_km: int
) -> float:
"""
Karbantartási időzítés pontosságának számítása.
Összehasonlítja a tényleges karbantartási költségeket az odometer állásokkal.
"""
# 1. Lekérjük a felhasználó járműveit
stmt = (
select(Vehicle)
.join(VehicleOwnership, VehicleOwnership.vehicle_id == Vehicle.id)
.where(VehicleOwnership.user_id == user_id)
.where(VehicleOwnership.is_active == True)
)
result = await db.execute(stmt)
vehicles = result.scalars().all()
if not vehicles:
logger.debug(f"Nincs aktív jármű a user_id={user_id} számára, maintenance_score=0.5")
return 0.5 # Alapértelmezett közepes érték
total_score = 0.0
vehicle_count = 0
for vehicle in vehicles:
# 2. Lekérjük a MAINTENANCE kategóriájú költségeket
stmt_costs = (
select(Cost)
.where(Cost.vehicle_id == vehicle.id)
.where(Cost.category == "MAINTENANCE")
.where(Cost.is_deleted == False)
.order_by(Cost.occurrence_date)
)
result_costs = await db.execute(stmt_costs)
maintenance_costs = result_costs.scalars().all()
if not maintenance_costs:
continue # Nincs karbantartási költség, nem számítunk bele
# 3. Összehasonlítjuk az odometer állásokkal
vehicle_score = await self._calculate_vehicle_maintenance_score(
db, vehicle, maintenance_costs, tolerance_km
)
total_score += vehicle_score
vehicle_count += 1
if vehicle_count == 0:
return 0.5
return total_score / vehicle_count
async def _calculate_vehicle_maintenance_score(
self,
db: AsyncSession,
vehicle: Vehicle,
maintenance_costs: list,
tolerance_km: int
) -> float:
"""Egy jármű karbantartási pontszámának számítása."""
# Egyszerűsített implementáció: csak ellenőrizzük, hogy vannak-e karbantartási költségek
# és hogy az odometer növekedése nem túl nagy a költségek között
# (Valós implementációban összehasonlítanánk a gyártói ajánlásokkal)
if len(maintenance_costs) < 2:
# Kevesebb mint 2 karbantartás, nem tudunk trendet elemezni
return 0.7
# Átlagos időköz a karbantartások között (km-ben)
total_km_gap = 0
gap_count = 0
for i in range(1, len(maintenance_costs)):
prev_cost = maintenance_costs[i-1]
curr_cost = maintenance_costs[i]
if prev_cost.odometer_km and curr_cost.odometer_km:
gap = curr_cost.odometer_km - prev_cost.odometer_km
total_km_gap += gap
gap_count += 1
if gap_count == 0:
return 0.7
avg_gap = total_km_gap / gap_count
# Ideális karbantartási intervallum (pl. 15,000 km)
ideal_interval = 15000
# Pontszám: minél közelebb van az ideálishoz, annál magasabb
deviation = abs(avg_gap - ideal_interval)
if deviation <= tolerance_km:
score = 1.0
elif deviation <= ideal_interval * 0.5: # 50%-nál kisebb eltérés
score = 0.8
elif deviation <= ideal_interval: # 100%-nál kisebb eltérés
score = 0.5
else:
score = 0.2
return score
async def _calculate_quality_score(
self,
db: AsyncSession,
user_id: int
) -> float:
"""
Szerviz minőségének számítása (ár/érték arány).
Egyszerűsített implementáció: átlagos értékelések alapján.
"""
# Jelenlegi implementáció: minden felhasználó kap egy alap pontszámot
# Valós implementációban a szervizek értékeléseit és árait elemeznénk
return 0.75 # Alapértelmezett közepes érték
async def _calculate_preventive_score(
self,
db: AsyncSession,
user_id: int
) -> float:
"""
Megelőző intézkedések pontszáma.
Egyszerűsített implementáció: idő előtti alkatrész cserék száma.
"""
# Jelenlegi implementáció: minden felhasználó kap egy alap pontszámot
# Valós implementációban a PREVENTIVE kategóriájú költségeket elemeznénk
return 0.6 # Alapértelmezett közepes érték
def _format_trust_response(self, profile: UserTrustProfile) -> Dict[str, Any]:
"""Formázza a trust profile-t válaszként."""
return {
"trust_score": profile.trust_score,
"maintenance_score": float(profile.maintenance_score),
"quality_score": float(profile.quality_score),
"preventive_score": float(profile.preventive_score),
"weights": {}, # Üres, mert nem számoltuk újra
"tolerance_km": None,
"last_calculated": profile.last_calculated.isoformat() if profile.last_calculated else None,
}