refaktorálás javításai
This commit is contained in:
441
backend/app/services/analytics_service.py
Normal file
441
backend/app/services/analytics_service.py
Normal 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
|
||||
183
backend/app/services/deduplication_service.py
Normal file
183
backend/app/services/deduplication_service.py
Normal 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),
|
||||
}
|
||||
}
|
||||
187
backend/app/services/financial_interfaces.py
Normal file
187
backend/app/services/financial_interfaces.py
Normal 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
|
||||
449
backend/app/services/financial_orchestrator.py
Normal file
449
backend/app/services/financial_orchestrator.py
Normal 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"""
|
||||
@@ -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
|
||||
|
||||
185
backend/app/services/logbook_service.py
Normal file
185
backend/app/services/logbook_service.py
Normal 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)
|
||||
269
backend/app/services/marketplace_service.py
Normal file
269
backend/app/services/marketplace_service.py
Normal 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étezik‑e 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étezik‑e 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_DAYS‑on 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étezik‑e é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 (1‑10)
|
||||
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ér‑aggregá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
|
||||
|
||||
# Trust‑score súlyozás: a felhasználók trust‑score‑já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
|
||||
|
||||
# Trust‑score 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ékelheti‑e a szervizt.
|
||||
|
||||
Returns:
|
||||
(can_review, reason)
|
||||
"""
|
||||
# 1. Van‑e 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. Van‑e a felhasználónak tranzakciója a szervizzel?
|
||||
# Megjegyzés: A tranzakció‑szerviz kapcsolat jelenleg nincs tárolva.
|
||||
# Ehhez a FinancialLedger‑ben kellene egy service_id mező, vagy
|
||||
# egy kapcsolótábla. Most csak annyit ellenőrzünk, hogy van‑e 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
|
||||
213
backend/app/services/odometer_service.py
Normal file
213
backend/app/services/odometer_service.py
Normal 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
|
||||
147
backend/app/services/system_service.py
Normal file
147
backend/app/services/system_service.py
Normal 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)
|
||||
343
backend/app/services/trust_engine.py
Normal file
343
backend/app/services/trust_engine.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user