Initial commit: Robot ökoszisztéma v2.0 - Stabilizált jármű és szerviz robotok
This commit is contained in:
64
backend/app/services/ai_ocr_service.py
Executable file
64
backend/app/services/ai_ocr_service.py
Executable file
@@ -0,0 +1,64 @@
|
||||
# app/services/ai_ocr_service.py
|
||||
import json
|
||||
import httpx
|
||||
import base64
|
||||
from app.schemas.evidence import RegistrationDocumentExtracted
|
||||
|
||||
class AiOcrService:
|
||||
OLLAMA_URL = "http://service_finder_ollama:11434/api/generate"
|
||||
MODEL_NAME = "llama3.2-vision"
|
||||
|
||||
@classmethod
|
||||
async def extract_registration_data(cls, clean_image_bytes: bytes) -> RegistrationDocumentExtracted:
|
||||
base64_image = base64.b64encode(clean_image_bytes).decode('utf-8')
|
||||
|
||||
prompt = """
|
||||
Te egy magyar hatósági okmány-szakértő AI vagy. A feladatod a mellékelt magyar forgalmi engedély (kép) összes adatának kinyerése.
|
||||
|
||||
Keresd meg és olvasd le az adatokat az alábbi hatósági kódok alapján:
|
||||
- A: Rendszám (kötőjellel, pl: ABC-123 vagy AA-BB-123)
|
||||
- B: Első nyilvántartásba vétel dátuma (YYYY.MM.DD)
|
||||
- C.1.1: Családi név vagy cégnév
|
||||
- C.1.2: Utónév
|
||||
- C.1.3: Teljes lakcím (Irsz, Város, Utca, Házszám)
|
||||
- C.4: Jogosultság (a = tulajdonos, b = üzembentartó)
|
||||
- D.1: Gyártmány (pl. TOYOTA, VOLKSWAGEN)
|
||||
- D.2: Jármű típusa
|
||||
- D.3: Kereskedelmi leírás (pl. COROLLA, GOLF)
|
||||
- E: Alvázszám (pontosan 17 karakter)
|
||||
- G: Saját tömeg (kg)
|
||||
- F.1: Együttes tömeg (kg)
|
||||
- P.1: Hengerűrtartalom (cm3)
|
||||
- P.2: Teljesítmény (kW)
|
||||
- P.3: Hajtóanyag (pl. Benzin, Gázolaj, Elektromos)
|
||||
- P.5: Motorkód
|
||||
- V.9: Környezetvédelmi osztály kódja
|
||||
- R: Szín
|
||||
- S.1: Ülések száma
|
||||
- H: Műszaki érvényesség vége (YYYY.MM.DD)
|
||||
- Sebességváltó: Keresd a 0, 1, 2, 3 kódokat (0=mechanikus, 2=automata).
|
||||
|
||||
VÁLASZ FORMÁTUMA: Kizárólag érvényes JSON. Ha egy adat nem olvasható, az értéke null legyen.
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"model": cls.MODEL_NAME,
|
||||
"prompt": prompt,
|
||||
"images": [base64_image],
|
||||
"stream": False,
|
||||
"format": "json"
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
try:
|
||||
response = await client.post(cls.OLLAMA_URL, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
ai_response_text = response.json().get("response", "{}")
|
||||
data_dict = json.loads(ai_response_text)
|
||||
|
||||
return RegistrationDocumentExtracted(**data_dict)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Robot 3 AI Hiba: {e}")
|
||||
raise ValueError(f"AI hiba az adatkivonás során: {str(e)}")
|
||||
104
backend/app/services/ai_service.py
Executable file
104
backend/app/services/ai_service.py
Executable file
@@ -0,0 +1,104 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/ai_service.py
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional, List
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.system import SystemParameter
|
||||
from app.services.config_service import config # 2.2-es központi config
|
||||
|
||||
logger = logging.getLogger("AI-Service-2.2")
|
||||
|
||||
class AIService:
|
||||
"""
|
||||
Sentinel Master AI Service 2.2.
|
||||
Felelős az LLM hívásokért, prompt sablonok kezeléséért és az OCR feldolgozásért.
|
||||
Minden paraméter (modell, url, prompt, hőmérséklet) adminból vezérelt.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def _execute_ai_call(cls, db, prompt: str, model_key: str = "text", images: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Központi AI végrehajtó. Kezeli a modellt, a várakozást és a JSON parzolást.
|
||||
"""
|
||||
try:
|
||||
# 1. ADMIN KONFIGURÁCIÓ LEKÉRÉSE
|
||||
base_url = await config.get_setting(db, "ai_ollama_url", default="http://ollama:11434/api/generate")
|
||||
delay = await config.get_setting(db, "AI_REQUEST_DELAY", default=0.1)
|
||||
|
||||
# Modell választás (text vagy vision)
|
||||
model_name = await config.get_setting(db, f"ai_model_{model_key}", default="qwen2.5-coder:32b")
|
||||
temp = await config.get_setting(db, "ai_temperature", default=0.1)
|
||||
timeout_val = await config.get_setting(db, "ai_timeout", default=120.0)
|
||||
|
||||
await asyncio.sleep(float(delay))
|
||||
|
||||
# 2. PAYLOAD ÖSSZEÁLLÍTÁSA
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": float(temp)}
|
||||
}
|
||||
|
||||
if images: # Llava/Vision támogatás
|
||||
payload["images"] = images
|
||||
|
||||
# 3. HTTP HÍVÁS
|
||||
async with httpx.AsyncClient(timeout=float(timeout_val)) as client:
|
||||
response = await client.post(base_url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
raw_res = response.json().get("response", "{}")
|
||||
return json.loads(raw_res)
|
||||
|
||||
except json.JSONDecodeError as je:
|
||||
logger.error(f"❌ AI JSON hiba (parszolási hiba): {je}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AI hívás kritikus hiba: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_gold_data_from_research(cls, make: str, model: str, raw_context: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Robot 3 (Alchemist) dúsító folyamata.
|
||||
Kutatási adatokból csinál tiszta technikai adatlapot.
|
||||
"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
template = await config.get_setting(db, "ai_prompt_gold_data",
|
||||
default="Extract technical car data for {make} {model} from: {context}")
|
||||
|
||||
full_prompt = template.format(make=make, model=model, context=raw_context)
|
||||
return await cls._execute_ai_call(db, full_prompt, model_key="text")
|
||||
|
||||
@classmethod
|
||||
async def get_clean_vehicle_data(cls, make: str, raw_model: str, sources: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Név normalizálás és szinonima gyűjtés.
|
||||
"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
template = await config.get_setting(db, "ai_prompt_normalization",
|
||||
default="Normalize car model names: {make} {model}. Sources: {sources}")
|
||||
|
||||
full_prompt = template.format(make=make, model=raw_model, sources=json.dumps(sources))
|
||||
return await cls._execute_ai_call(db, full_prompt, model_key="text")
|
||||
|
||||
@classmethod
|
||||
async def process_ocr_document(cls, doc_type: str, base64_image: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Robot 1 (OCR) látó folyamata.
|
||||
Képet (base64) küld a Vision modellnek (pl. Llava).
|
||||
"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Külön prompt sablon minden dokumentum típushoz (számla, forgalmi, adásvételi)
|
||||
template = await config.get_setting(db, f"ai_prompt_ocr_{doc_type}",
|
||||
default="Analyze this {doc_type} image and return structured JSON data.")
|
||||
|
||||
full_prompt = template.format(doc_type=doc_type)
|
||||
return await cls._execute_ai_call(db, full_prompt, model_key="vision", images=[base64_image])
|
||||
141
backend/app/services/ai_service1.1.0.py
Executable file
141
backend/app/services/ai_service1.1.0.py
Executable file
@@ -0,0 +1,141 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
import base64
|
||||
import httpx
|
||||
from typing import Dict, Any, Optional, List
|
||||
from sqlalchemy import select
|
||||
from app.db.session import SessionLocal
|
||||
from app.models import SystemParameter
|
||||
|
||||
logger = logging.getLogger("AI-Service")
|
||||
|
||||
class AIService:
|
||||
"""
|
||||
AI Service v1.3.5 - Private High-Performance Edition
|
||||
- Engine: Local Ollama (GPU Accelerated)
|
||||
- Features: DVLA Integration, 50-char Normalization, Private OCR
|
||||
"""
|
||||
|
||||
# A Docker belső hálózatán a szerviznév 'ollama'
|
||||
OLLAMA_BASE_URL = "http://ollama:11434/api/generate"
|
||||
TEXT_MODEL = "vehicle-pro"
|
||||
VISION_MODEL = "llava:7b"
|
||||
DVLA_API_KEY = os.getenv("DVLA_API_KEY")
|
||||
|
||||
@classmethod
|
||||
async def get_config_delay(cls) -> float:
|
||||
"""Késleltetés lekérése az adatbázisból."""
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
stmt = select(SystemParameter).where(SystemParameter.key == "AI_REQUEST_DELAY")
|
||||
res = await db.execute(stmt)
|
||||
param = res.scalar_one_or_none()
|
||||
return float(param.value) if param else 0.1
|
||||
except Exception:
|
||||
return 0.1
|
||||
|
||||
@classmethod
|
||||
async def get_clean_vehicle_data(cls, make: str, raw_model: str, v_type: str, sources: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Robot 2: Adat-összefésülés és normalizálás."""
|
||||
# Várjunk egy kicsit a GPU kímélése érdekében
|
||||
await asyncio.sleep(await cls.get_config_delay())
|
||||
|
||||
prompt = f"""
|
||||
FELADAT: Normalizáld a jármű adatait több forrás alapján.
|
||||
GYÁRTÓ: {make}
|
||||
NYERS MODELLNÉV: {raw_model}
|
||||
FORRÁSOK NYERS ADATAI: {json.dumps(sources, ensure_ascii=False)}
|
||||
|
||||
SZIGORÚ SZABÁLYOK:
|
||||
1. 'marketing_name': MAXIMUM 50 KARAKTER!
|
||||
2. 'synonyms': Gyűjtsd ide az összes többi névváltozatot.
|
||||
3. 'technical_code': Keresd meg a gyári kódokat.
|
||||
|
||||
VÁLASZ FORMÁTUM (Csak tiszta JSON):
|
||||
{{
|
||||
"marketing_name": "string (max 50)",
|
||||
"synonyms": ["string"],
|
||||
"technical_code": "string",
|
||||
"ccm": int,
|
||||
"kw": int,
|
||||
"euro_class": int,
|
||||
"year_from": int,
|
||||
"year_to": int vagy null,
|
||||
"maintenance": {{
|
||||
"oil_type": "string",
|
||||
"oil_qty": float,
|
||||
"spark_plug": "string"
|
||||
}},
|
||||
"is_duplicate_potential": bool
|
||||
}}
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"model": cls.TEXT_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0.1}
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
logger.info(f"📡 AI kérés küldése: {make} {raw_model}...")
|
||||
response = await client.post(cls.OLLAMA_BASE_URL, json=payload)
|
||||
response.raise_for_status()
|
||||
res_json = response.json()
|
||||
clean_data = json.loads(res_json.get("response", "{}"))
|
||||
|
||||
if clean_data.get("marketing_name"):
|
||||
clean_data["marketing_name"] = clean_data["marketing_name"][:50].strip()
|
||||
|
||||
return clean_data
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AI hiba ({make} {raw_model}): {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_dvla_data(cls, vrm: str) -> Optional[Dict[str, Any]]:
|
||||
"""Brit rendszám alapú adatok lekérése."""
|
||||
if not cls.DVLA_API_KEY: return None
|
||||
url = "https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles"
|
||||
headers = {"x-api-key": cls.DVLA_API_KEY, "Content-Type": "application/json"}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(url, json={"registrationNumber": vrm}, headers=headers)
|
||||
return resp.json() if resp.status_code == 200 else None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ DVLA API hiba: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def analyze_document_image(cls, image_data: bytes, doc_type: str) -> Optional[Dict[str, Any]]:
|
||||
"""Robot 3: Helyi OCR és dokumentum elemzés (Llava:7b)."""
|
||||
await asyncio.sleep(await cls.get_config_delay())
|
||||
prompts = {
|
||||
"identity": "Extract ID card data (name, id_number, expiry) as JSON.",
|
||||
"vehicle_reg": "Extract vehicle registration (plate, VIN, power_kw, engine_ccm) as JSON.",
|
||||
"invoice": "Extract invoice details (vendor, total_amount, date) as JSON.",
|
||||
"odometer": "Identify the number on the odometer and return as JSON: {'value': int}."
|
||||
}
|
||||
img_b64 = base64.b64encode(image_data).decode('utf-8')
|
||||
payload = {
|
||||
"model": cls.VISION_MODEL,
|
||||
"prompt": prompts.get(doc_type, "Perform OCR and return JSON"),
|
||||
"images": [img_b64],
|
||||
"stream": False,
|
||||
"format": "json"
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(cls.OLLAMA_BASE_URL, json=payload)
|
||||
res_data = response.json()
|
||||
clean_json = res_data.get("response", "{}")
|
||||
match = re.search(r'\{.*\}', clean_json, re.DOTALL)
|
||||
return json.loads(match.group()) if match else json.loads(clean_json)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Helyi OCR hiba: {e}")
|
||||
return None
|
||||
111
backend/app/services/ai_service_googleApi_old.py
Executable file
111
backend/app/services/ai_service_googleApi_old.py
Executable file
@@ -0,0 +1,111 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Dict, Any, Optional
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from sqlalchemy import select
|
||||
from app.db.session import SessionLocal
|
||||
from app.models import SystemParameter
|
||||
|
||||
logger = logging.getLogger("AI-Service")
|
||||
|
||||
class AIService:
|
||||
"""
|
||||
AI Service v1.2.5 - Final Integrated Edition
|
||||
- Robot 2: Technikai dúsítás (Search + Regex JSON parsing)
|
||||
- Robot 3: OCR (Controlled JSON generation)
|
||||
"""
|
||||
api_key = os.getenv("GEMINI_API_KEY")
|
||||
client = genai.Client(api_key=api_key) if api_key else None
|
||||
PRIMARY_MODEL = "gemini-2.0-flash"
|
||||
|
||||
@classmethod
|
||||
async def get_config_delay(cls) -> float:
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
stmt = select(SystemParameter).where(SystemParameter.key == "AI_REQUEST_DELAY")
|
||||
res = await db.execute(stmt)
|
||||
param = res.scalar_one_or_none()
|
||||
return float(param.value) if param else 1.0
|
||||
except Exception: return 1.0
|
||||
|
||||
@classmethod
|
||||
async def get_clean_vehicle_data(cls, make: str, raw_model: str, v_type: str) -> Optional[Dict[str, Any]]:
|
||||
"""Robot 2: Adatbányászat Google Search segítségével."""
|
||||
if not cls.client: return None
|
||||
await asyncio.sleep(await cls.get_config_delay())
|
||||
|
||||
search_tool = types.Tool(google_search=types.GoogleSearch())
|
||||
|
||||
prompt = f"""
|
||||
KERESS RÁ az interneten: {make} {raw_model} ({v_type}) pontos gyári modellkódja és technikai adatai.
|
||||
Adj választ szigorúan csak egy JSON blokkban:
|
||||
{{
|
||||
"marketing_name": "tiszta név",
|
||||
"synonyms": ["név1", "név2"],
|
||||
"technical_code": "gyári kód",
|
||||
"year_from": int,
|
||||
"year_to": int_vagy_null,
|
||||
"ccm": int,
|
||||
"kw": int,
|
||||
"maintenance": {{ "oil_type": "string", "oil_qty": float, "spark_plug": "string", "coolant": "string" }}
|
||||
}}
|
||||
FONTOS: A 'technical_code' NEM lehet üres. Ha nem találod, adj 'N/A' értéket!
|
||||
"""
|
||||
|
||||
# Search tool használata esetén a response_mime_type tilos!
|
||||
config = types.GenerateContentConfig(
|
||||
system_instruction="Profi járműtechnikai adatbányász vagy. Csak tiszta JSON-t válaszolsz markdown kódblokk nélkül.",
|
||||
tools=[search_tool],
|
||||
temperature=0.1
|
||||
)
|
||||
|
||||
try:
|
||||
response = cls.client.models.generate_content(model=cls.PRIMARY_MODEL, contents=prompt, config=config)
|
||||
text = response.text
|
||||
# Tisztítás: ha az AI mégis tenne bele markdown jeleket
|
||||
clean_json = re.sub(r'```json\s*|```', '', text).strip()
|
||||
res_json = json.loads(clean_json)
|
||||
if isinstance(res_json, list) and len(res_json) > 0: res_json = res_json[0]
|
||||
return res_json if isinstance(res_json, dict) else None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AI hiba ({make} {raw_model}): {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def analyze_document_image(cls, image_data: bytes, doc_type: str) -> Optional[Dict[str, Any]]:
|
||||
"""Robot 3: OCR funkció - Forgalmi, Személyi, Számla, Odometer."""
|
||||
if not cls.client: return None
|
||||
await asyncio.sleep(await cls.get_config_delay())
|
||||
|
||||
prompts = {
|
||||
"identity": "Személyes okmány adatok (név, szám, lejárat).",
|
||||
"vehicle_reg": "Forgalmi adatok (rendszám, alvázszám, kW, ccm).",
|
||||
"invoice": "Számla adatok (partner, végösszeg, dátum).",
|
||||
"odometer": "Csak a kilométeróra állása számként."
|
||||
}
|
||||
|
||||
# Itt maradhat a response_mime_type, mert nem használunk Search-öt
|
||||
config = types.GenerateContentConfig(
|
||||
system_instruction="Profi OCR dokumentum-elemző vagy. Csak tiszta JSON-t válaszolsz.",
|
||||
response_mime_type="application/json"
|
||||
)
|
||||
|
||||
try:
|
||||
response = cls.client.models.generate_content(
|
||||
model=cls.PRIMARY_MODEL,
|
||||
contents=[
|
||||
f"Elemezd ezt a képet ({doc_type}): {prompts.get(doc_type, 'OCR')}",
|
||||
types.Part.from_bytes(data=image_data, mime_type="image/jpeg")
|
||||
],
|
||||
config=config
|
||||
)
|
||||
res_json = json.loads(response.text)
|
||||
if isinstance(res_json, list) and len(res_json) > 0: res_json = res_json[0]
|
||||
return res_json if isinstance(res_json, dict) else None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ OCR hiba: {e}")
|
||||
return None
|
||||
153
backend/app/services/asset_service.py
Executable file
153
backend/app/services/asset_service.py
Executable file
@@ -0,0 +1,153 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/asset_service.py
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.asset import Asset, AssetAssignment, AssetTelemetry, AssetFinancials
|
||||
from app.models.identity import User
|
||||
from app.services.config_service import config
|
||||
from app.services.gamification_service import GamificationService
|
||||
from app.services.security_service import security_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .identity import User, Person
|
||||
from .organization import Organization
|
||||
from .vehicle_definitions import VehicleModelDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AssetService:
|
||||
"""
|
||||
Asset Service 2.0 - A Járművek Életciklus-menedzsere.
|
||||
Kezeli a regisztrációt, a tulajdonosváltást és a flotta-korlátokat.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_or_claim_vehicle(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
org_id: int,
|
||||
vin: str,
|
||||
license_plate: str,
|
||||
catalog_id: int = None
|
||||
):
|
||||
"""
|
||||
Intelligens Jármű Rögzítés:
|
||||
Ha új: létrehozza.
|
||||
Ha már létezik: Transzfer folyamatot indít.
|
||||
"""
|
||||
try:
|
||||
vin_clean = vin.strip().upper()
|
||||
|
||||
# 1. ADMIN LIMIT ELLENŐRZÉS
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one()
|
||||
|
||||
limits = await config.get_setting(db, "VEHICLE_LIMIT", default={"free": 1, "premium": 5, "vip": 50})
|
||||
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
allowed_limit = limits.get(user_role, 1)
|
||||
|
||||
count_stmt = select(func.count(Asset.id)).where(Asset.current_organization_id == org_id)
|
||||
current_count = (await db.execute(count_stmt)).scalar()
|
||||
|
||||
if current_count >= allowed_limit:
|
||||
raise ValueError(f"Limit túllépés! A csomagod {allowed_limit} autót engedélyez.")
|
||||
|
||||
# 2. LÉTEZIK-E MÁR A JÁRMŰ?
|
||||
stmt = select(Asset).where(Asset.vin == vin_clean)
|
||||
existing_asset = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if existing_asset:
|
||||
# HA MÁR A JELENLEGI SZERVEZETNÉL VAN
|
||||
if existing_asset.current_organization_id == org_id:
|
||||
raise ValueError("Ez a jármű már a te garázsodban van.")
|
||||
|
||||
# TRANSZFER FOLYAMAT INDÍTÁSA
|
||||
return await AssetService.initiate_ownership_transfer(
|
||||
db, existing_asset, user_id, org_id, license_plate
|
||||
)
|
||||
|
||||
# 3. ÚJ JÁRMŰ LÉTREHOZÁSA (Standard Flow)
|
||||
new_asset = Asset(
|
||||
vin=vin_clean,
|
||||
license_plate=license_plate.strip().upper(),
|
||||
catalog_id=catalog_id,
|
||||
current_organization_id=org_id,
|
||||
status="active",
|
||||
is_verified=False
|
||||
)
|
||||
db.add(new_asset)
|
||||
await db.flush()
|
||||
|
||||
# Digitális Iker Alapmodulok
|
||||
db.add(AssetAssignment(asset_id=new_asset.id, organization_id=org_id, status="active"))
|
||||
db.add(AssetTelemetry(asset_id=new_asset.id))
|
||||
db.add(AssetFinancials(asset_id=new_asset.id))
|
||||
|
||||
# Gamification
|
||||
reward = await config.get_setting(db, "xp_reward_asset_register", default=250)
|
||||
await GamificationService.award_points(db, user_id, int(reward), "NEW_ASSET_REG")
|
||||
|
||||
await db.commit()
|
||||
return new_asset
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Asset Creation Error: {e}")
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
async def initiate_ownership_transfer(db: AsyncSession, asset: Asset, user_id: int, org_id: int, new_plate: str):
|
||||
"""
|
||||
Adásvétel kezelése: Az autót 'Transfer Pending' állapotba teszi.
|
||||
"""
|
||||
# Admin paraméter: Automatikus transzfer engedélyezése?
|
||||
auto_transfer = await config.get_setting(db, "asset_auto_transfer_enabled", default=False)
|
||||
|
||||
# Logoljuk a kísérletet a biztonsági szolgálatnál (Sentinel)
|
||||
await security_service.log_event(
|
||||
db, user_id=user_id, action="VEHICLE_CLAIM_INITIATED",
|
||||
severity="warning", target_type="Asset", target_id=str(asset.id),
|
||||
new_data={"vin": asset.vin, "new_org": org_id}
|
||||
)
|
||||
|
||||
if auto_transfer:
|
||||
# Csak akkor, ha a régi tulajdonos 'sold' állapotba tette
|
||||
if asset.status == "sold":
|
||||
return await AssetService.execute_final_transfer(db, asset, org_id, new_plate)
|
||||
|
||||
# Függőben lévő állapot: Dokumentum feltöltésre vár
|
||||
asset.status = "transfer_pending"
|
||||
asset.temp_claim_org_id = org_id # Átmeneti tároló a validálásig
|
||||
|
||||
await db.commit()
|
||||
# Itt egy speciális hibaüzenetet dobunk, amit a Frontend tud kezelni (Dokumentum feltöltő ablak)
|
||||
raise HTTPException(
|
||||
status_code=202,
|
||||
detail="A jármű már szerepel a rendszerben. Kérjük, töltsd fel az adásvételi szerződést a tulajdonjog igazolásához."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def execute_final_transfer(db: AsyncSession, asset: Asset, new_org_id: int, new_plate: str):
|
||||
""" A tulajdonjog tényleges átírása az adatbázisban. """
|
||||
# 1. Régi hozzárendelés lezárása
|
||||
await db.execute(
|
||||
update(AssetAssignment)
|
||||
.where(and_(AssetAssignment.asset_id == asset.id, AssetAssignment.status == "active"))
|
||||
.values(status="archived", end_date=datetime.now())
|
||||
)
|
||||
|
||||
# 2. Új hozzárendelés és adatok frissítése
|
||||
asset.current_organization_id = new_org_id
|
||||
asset.license_plate = new_plate.upper()
|
||||
asset.status = "active"
|
||||
asset.is_verified = False # Az új tulajdonos papírjait is ellenőrizni kell!
|
||||
|
||||
db.add(AssetAssignment(asset_id=asset.id, organization_id=new_org_id, status="active"))
|
||||
|
||||
await db.commit()
|
||||
return asset
|
||||
258
backend/app/services/auth_service.py
Executable file
258
backend/app/services/auth_service.py
Executable file
@@ -0,0 +1,258 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/auth_service.py
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, update
|
||||
from sqlalchemy.orm import joinedload
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
|
||||
from app.models.gamification import UserStats
|
||||
from app.models.organization import Organization, OrganizationMember, OrgType, Branch
|
||||
from app.schemas.auth import UserLiteRegister, UserKYCComplete
|
||||
from app.core.security import get_password_hash, verify_password, generate_secure_slug
|
||||
from app.services.email_manager import email_manager
|
||||
from app.core.config import settings
|
||||
from app.services.config_service import config
|
||||
from app.services.geo_service import GeoService
|
||||
from app.services.security_service import security_service
|
||||
from app.services.gamification_service import GamificationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuthService:
|
||||
@staticmethod
|
||||
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
|
||||
""" 1. FÁZIS: Lite regisztráció dinamikus korlátokkal és Sentinel naplózással. """
|
||||
try:
|
||||
# Paraméterek lekérése az admin felületről
|
||||
min_pass = await config.get_setting(db, "auth_min_password_length", default=8)
|
||||
default_role_name = await config.get_setting(db, "auth_default_role", default="user")
|
||||
reg_token_hours = await config.get_setting(db, "auth_registration_hours", region_code=user_in.region_code, default=48)
|
||||
|
||||
if len(user_in.password) < int(min_pass):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"A jelszónak legalább {min_pass} karakter hosszúnak kell lennie."
|
||||
)
|
||||
|
||||
new_person = Person(
|
||||
first_name=user_in.first_name,
|
||||
last_name=user_in.last_name,
|
||||
is_active=False
|
||||
)
|
||||
db.add(new_person)
|
||||
await db.flush()
|
||||
|
||||
# Szerepkör dinamikus feloldása
|
||||
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.user
|
||||
|
||||
new_user = User(
|
||||
email=user_in.email,
|
||||
hashed_password=get_password_hash(user_in.password),
|
||||
person_id=new_person.id,
|
||||
role=assigned_role,
|
||||
is_active=False,
|
||||
is_deleted=False,
|
||||
region_code=user_in.region_code,
|
||||
preferred_language=user_in.lang,
|
||||
timezone=user_in.timezone
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
# Verifikációs token
|
||||
token_val = uuid.uuid4()
|
||||
db.add(VerificationToken(
|
||||
token=token_val,
|
||||
user_id=new_user.id,
|
||||
token_type="registration",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_token_hours))
|
||||
))
|
||||
|
||||
# Email küldés a beállított template alapján
|
||||
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
|
||||
await email_manager.send_email(
|
||||
recipient=user_in.email,
|
||||
template_key="reg",
|
||||
variables={"first_name": user_in.first_name, "link": verification_link},
|
||||
lang=user_in.lang
|
||||
)
|
||||
|
||||
# Sentinel Audit Log
|
||||
await security_service.log_event(
|
||||
db, user_id=new_user.id, action="USER_REGISTER_LITE",
|
||||
severity="info", target_type="User", target_id=str(new_user.id),
|
||||
new_data={"email": user_in.email}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return new_user
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Lite Reg Error: {e}")
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
|
||||
""" 2. FÁZIS: Teljes profil és Gamification inicializálás. """
|
||||
try:
|
||||
stmt = select(User).options(joinedload(User.person)).where(User.id == user_id)
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not user: return None
|
||||
|
||||
# Dinamikus beállítások (Soha ne legyen kódba vésve!)
|
||||
org_tpl = await config.get_setting(db, "org_naming_template", default="{last_name} Flotta")
|
||||
base_cur = await config.get_setting(db, "finance_default_currency", region_code=user.region_code, default="HUF")
|
||||
kyc_reward = await config.get_setting(db, "gamification_kyc_bonus", default=500)
|
||||
|
||||
# Címkezelés (GeoService hívás)
|
||||
addr_id = await GeoService.get_or_create_full_address(
|
||||
db, zip_code=kyc_in.address_zip, city=kyc_in.address_city,
|
||||
street_name=kyc_in.address_street_name, street_type=kyc_in.address_street_type,
|
||||
house_number=kyc_in.address_house_number, parcel_id=kyc_in.address_hrsz
|
||||
)
|
||||
|
||||
# Person adatok dúsítása
|
||||
p = user.person
|
||||
p.mothers_last_name = kyc_in.mothers_last_name
|
||||
p.mothers_first_name = kyc_in.mothers_first_name
|
||||
p.birth_place = kyc_in.birth_place
|
||||
p.birth_date = kyc_in.birth_date
|
||||
p.phone = kyc_in.phone_number
|
||||
p.address_id = addr_id
|
||||
p.identity_docs = jsonable_encoder(kyc_in.identity_docs)
|
||||
p.is_active = True
|
||||
|
||||
# Dinamikus szervezet generálás
|
||||
org_full_name = org_tpl.format(last_name=p.last_name, first_name=p.first_name)
|
||||
new_org = Organization(
|
||||
full_name=org_full_name,
|
||||
name=f"{p.last_name} Széfe",
|
||||
folder_slug=generate_secure_slug(12),
|
||||
org_type=OrgType.individual,
|
||||
owner_id=user.id,
|
||||
is_active=True,
|
||||
status="verified",
|
||||
country_code=user.region_code
|
||||
)
|
||||
db.add(new_org)
|
||||
await db.flush()
|
||||
|
||||
# Infrastruktúra elemek
|
||||
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Home Base", is_main=True))
|
||||
db.add(OrganizationMember(organization_id=new_org.id, user_id=user.id, role="OWNER"))
|
||||
db.add(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or base_cur))
|
||||
db.add(UserStats(user_id=user.id))
|
||||
|
||||
user.is_active = True
|
||||
user.folder_slug = generate_secure_slug(12)
|
||||
|
||||
# Gamification XP jóváírás
|
||||
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION")
|
||||
|
||||
await db.commit()
|
||||
return user
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"KYC Error: {e}")
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
async def authenticate(db: AsyncSession, email: str, password: str):
|
||||
""" Felhasználó hitelesítése. """
|
||||
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
|
||||
res = await db.execute(stmt)
|
||||
user = res.scalar_one_or_none()
|
||||
if user and verify_password(password, user.hashed_password):
|
||||
return user
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def verify_email(db: AsyncSession, token_str: str):
|
||||
""" Email megerősítés. """
|
||||
try:
|
||||
token_uuid = uuid.UUID(token_str)
|
||||
stmt = select(VerificationToken).where(and_(
|
||||
VerificationToken.token == token_uuid,
|
||||
VerificationToken.is_used == False,
|
||||
VerificationToken.expires_at > datetime.now(timezone.utc)
|
||||
))
|
||||
token = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not token: return False
|
||||
|
||||
token.is_used = True
|
||||
# Itt aktiválhatnánk a júzert, ha a Lite regnél még nem tennénk meg
|
||||
await db.commit()
|
||||
return True
|
||||
except: return False
|
||||
|
||||
@staticmethod
|
||||
async def initiate_password_reset(db: AsyncSession, email: str):
|
||||
""" Elfelejtett jelszó folyamat indítása. """
|
||||
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
# Dinamikus lejárat az adminból
|
||||
reset_h = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2)
|
||||
token_val = uuid.uuid4()
|
||||
db.add(VerificationToken(
|
||||
token=token_val, user_id=user.id, token_type="password_reset",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_h))
|
||||
))
|
||||
|
||||
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
|
||||
await email_manager.send_email(
|
||||
recipient=email, template_key="pwd_reset",
|
||||
variables={"link": link}, lang=user.preferred_language
|
||||
)
|
||||
await db.commit()
|
||||
return "success"
|
||||
return "not_found"
|
||||
|
||||
@staticmethod
|
||||
async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str):
|
||||
""" Jelszó tényleges megváltoztatása token alapján. """
|
||||
try:
|
||||
token_uuid = uuid.UUID(token_str)
|
||||
stmt = select(VerificationToken).join(User).where(and_(
|
||||
User.email == email,
|
||||
VerificationToken.token == token_uuid,
|
||||
VerificationToken.token_type == "password_reset",
|
||||
VerificationToken.is_used == False,
|
||||
VerificationToken.expires_at > datetime.now(timezone.utc)
|
||||
))
|
||||
token_rec = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not token_rec: return False
|
||||
|
||||
user_stmt = select(User).where(User.id == token_rec.user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one()
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
token_rec.is_used = True
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
except: return False
|
||||
|
||||
@staticmethod
|
||||
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
|
||||
""" Felhasználó törlése (Soft-Delete) auditálással. """
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not user or user.is_deleted: return False
|
||||
|
||||
old_email = user.email
|
||||
user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}"
|
||||
user.is_deleted = True
|
||||
user.is_active = False
|
||||
|
||||
await security_service.log_event(
|
||||
db, user_id=actor_id, action="USER_SOFT_DELETE",
|
||||
severity="warning", target_type="User", target_id=str(user_id),
|
||||
new_data={"reason": reason}
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
281
backend/app/services/auth_service.py.old_1
Executable file
281
backend/app/services/auth_service.py.old_1
Executable file
@@ -0,0 +1,281 @@
|
||||
import os
|
||||
import logging
|
||||
import uuid
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.orm import joinedload
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
|
||||
from app.models.gamification import UserStats
|
||||
from app.models.organization import Organization, OrganizationMember, OrgType
|
||||
from app.schemas.auth import UserLiteRegister, UserKYCComplete
|
||||
from app.core.security import get_password_hash, verify_password, generate_secure_slug
|
||||
from app.services.email_manager import email_manager
|
||||
from app.core.config import settings
|
||||
from app.services.config_service import config
|
||||
from app.services.geo_service import GeoService
|
||||
from app.services.security_service import security_service # Sentinel integráció
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuthService:
|
||||
@staticmethod
|
||||
async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
|
||||
"""
|
||||
Step 1: Lite Regisztráció (Manuális).
|
||||
Létrehozza a Person és User rekordokat, de a fiók inaktív marad.
|
||||
A folder_slug itt még NEM generálódik le!
|
||||
"""
|
||||
try:
|
||||
# --- Dinamikus jelszóhossz ellenőrzés ---
|
||||
# Lekérjük az admin beállítást, minimum 8 karakter a hard limit.
|
||||
min_pass = await config.get_setting(db, "auth_min_password_length", default=8)
|
||||
min_len = max(int(min_pass), 8)
|
||||
|
||||
if len(user_in.password) < min_len:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"A jelszónak legalább {min_len} karakter hosszúnak kell lennie."
|
||||
)
|
||||
|
||||
new_person = Person(
|
||||
first_name=user_in.first_name,
|
||||
last_name=user_in.last_name,
|
||||
is_active=False
|
||||
)
|
||||
db.add(new_person)
|
||||
await db.flush()
|
||||
|
||||
new_user = User(
|
||||
email=user_in.email,
|
||||
hashed_password=get_password_hash(user_in.password),
|
||||
person_id=new_person.id,
|
||||
role=UserRole.user,
|
||||
is_active=False,
|
||||
is_deleted=False,
|
||||
region_code=user_in.region_code,
|
||||
preferred_language=user_in.lang,
|
||||
timezone=user_in.timezone
|
||||
# folder_slug marad NULL a Step 2-ig
|
||||
)
|
||||
db.add(new_user)
|
||||
await db.flush()
|
||||
|
||||
# Verifikációs token generálása
|
||||
reg_hours = await config.get_setting(db, "auth_registration_hours", region_code=user_in.region_code, default=48)
|
||||
token_val = uuid.uuid4()
|
||||
db.add(VerificationToken(
|
||||
token=token_val,
|
||||
user_id=new_user.id,
|
||||
token_type="registration",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours))
|
||||
))
|
||||
|
||||
# Email kiküldése
|
||||
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
|
||||
await email_manager.send_email(
|
||||
recipient=user_in.email,
|
||||
template_key="reg",
|
||||
variables={"first_name": user_in.first_name, "link": verification_link},
|
||||
lang=user_in.lang
|
||||
)
|
||||
|
||||
# Audit log a regisztrációról
|
||||
await security_service.log_event(
|
||||
db,
|
||||
user_id=new_user.id,
|
||||
action="USER_REGISTER_LITE",
|
||||
severity="info",
|
||||
target_type="User",
|
||||
target_id=str(new_user.id),
|
||||
new_data={"email": user_in.email, "method": "manual"}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
return new_user
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Registration Error: {str(e)}")
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
|
||||
""" Step 2: Atomi Tranzakció (Person + Address + Org + Branch + Wallet). """
|
||||
try:
|
||||
# 1. Lekérés Eager Loadinggal a hibák elkerülésére
|
||||
stmt = select(User).options(joinedload(User.person)).where(User.id == user_id)
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not user: return None
|
||||
|
||||
# 2. Cím rögzítése
|
||||
addr_id = await GeoService.get_or_create_full_address(
|
||||
db, zip_code=kyc_in.address_zip, city=kyc_in.address_city,
|
||||
street_name=kyc_in.address_street_name, street_type=kyc_in.address_street_type,
|
||||
house_number=kyc_in.address_house_number, parcel_id=kyc_in.address_hrsz
|
||||
)
|
||||
|
||||
# 3. Person adatok frissítése (MDM elv)
|
||||
p = user.person
|
||||
p.mothers_last_name = kyc_in.mothers_last_name
|
||||
p.mothers_first_name = kyc_in.mothers_first_name
|
||||
p.birth_place = kyc_in.birth_place
|
||||
p.birth_date = kyc_in.birth_date
|
||||
p.phone = kyc_in.phone_number
|
||||
p.address_id = addr_id
|
||||
p.identity_docs = jsonable_encoder(kyc_in.identity_docs)
|
||||
p.is_active = True
|
||||
|
||||
# 4. Individual Organization (Privát Széf) létrehozása
|
||||
new_org = Organization(
|
||||
full_name=f"{p.last_name} {p.first_name} Magán Flotta",
|
||||
name=f"{p.last_name} Flotta",
|
||||
folder_slug=generate_secure_slug(12),
|
||||
org_type=OrgType.individual,
|
||||
owner_id=user.id,
|
||||
is_active=True,
|
||||
status="verified",
|
||||
country_code=user.region_code
|
||||
)
|
||||
db.add(new_org)
|
||||
await db.flush()
|
||||
|
||||
# 5. Telephely (Branch) és Tagság
|
||||
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Otthon", is_main=True))
|
||||
db.add(OrganizationMember(organization_id=new_org.id, user_id=user.id, role="OWNER"))
|
||||
db.add(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or "HUF"))
|
||||
db.add(UserStats(user_id=user.id))
|
||||
|
||||
# 6. Aktiválás
|
||||
user.is_active = True
|
||||
user.folder_slug = generate_secure_slug(12)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"KYC Error: {e}")
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
|
||||
"""
|
||||
Soft-Delete: Email felszabadítás és izoláció.
|
||||
"""
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not user or user.is_deleted:
|
||||
return False
|
||||
|
||||
old_email = user.email
|
||||
# Email átnevezése az egyediség megőrzése érdekében (újraregisztrációhoz)
|
||||
user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}"
|
||||
user.is_deleted = True
|
||||
user.is_active = False
|
||||
|
||||
await security_service.log_event(
|
||||
db,
|
||||
user_id=actor_id,
|
||||
action="USER_SOFT_DELETE",
|
||||
severity="warning",
|
||||
target_type="User",
|
||||
target_id=str(user_id),
|
||||
old_data={"email": old_email},
|
||||
new_data={"is_deleted": True, "reason": reason}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def verify_email(db: AsyncSession, token_str: str):
|
||||
try:
|
||||
token_uuid = uuid.UUID(token_str)
|
||||
stmt = select(VerificationToken).where(
|
||||
and_(
|
||||
VerificationToken.token == token_uuid,
|
||||
VerificationToken.is_used == False,
|
||||
VerificationToken.expires_at > datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
res = await db.execute(stmt)
|
||||
token = res.scalar_one_or_none()
|
||||
if not token: return False
|
||||
|
||||
token.is_used = True
|
||||
await db.commit()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def authenticate(db: AsyncSession, email: str, password: str):
|
||||
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
|
||||
res = await db.execute(stmt)
|
||||
user = res.scalar_one_or_none()
|
||||
if user and verify_password(password, user.hashed_password):
|
||||
return user
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def initiate_password_reset(db: AsyncSession, email: str):
|
||||
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
reset_hours = await config.get_setting(db, "auth_password_reset_hours", region_code=user.region_code, default=2)
|
||||
token_val = uuid.uuid4()
|
||||
db.add(VerificationToken(
|
||||
token=token_val,
|
||||
user_id=user.id,
|
||||
token_type="password_reset",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_hours))
|
||||
))
|
||||
|
||||
reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
|
||||
await email_manager.send_email(
|
||||
recipient=email,
|
||||
template_key="pwd_reset",
|
||||
variables={"link": reset_link},
|
||||
lang=user.preferred_language
|
||||
)
|
||||
await db.commit()
|
||||
return "success"
|
||||
return "not_found"
|
||||
|
||||
@staticmethod
|
||||
async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str):
|
||||
try:
|
||||
token_uuid = uuid.UUID(token_str)
|
||||
stmt = select(VerificationToken).join(User).where(
|
||||
and_(
|
||||
User.email == email,
|
||||
VerificationToken.token == token_uuid,
|
||||
VerificationToken.token_type == "password_reset",
|
||||
VerificationToken.is_used == False,
|
||||
VerificationToken.expires_at > datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
token_rec = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not token_rec: return False
|
||||
|
||||
user_stmt = select(User).where(User.id == token_rec.user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one()
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
token_rec.is_used = True
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
83
backend/app/services/config_service.py
Executable file
83
backend/app/services/config_service.py
Executable file
@@ -0,0 +1,83 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/config_service.py
|
||||
from typing import Any, Optional, Dict
|
||||
import logging
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
# Modellek importálása a központi helyről
|
||||
from app.models import ExchangeRate, AssetCost, AssetTelemetry
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CostService:
|
||||
# A cost_in típusát 'Any'-re állítottam ideiglenesen, hogy ne dobjon újabb ImportError-t a hiányzó Pydantic séma miatt
|
||||
async def record_cost(self, db: AsyncSession, cost_in: Any, user_id: int):
|
||||
try:
|
||||
# 1. Árfolyam lekérése (EUR Pivot)
|
||||
rate_stmt = select(ExchangeRate).where(
|
||||
ExchangeRate.target_currency == cost_in.currency_local
|
||||
).order_by(ExchangeRate.id.desc()).limit(1)
|
||||
|
||||
rate_res = await db.execute(rate_stmt)
|
||||
rate_obj = rate_res.scalar_one_or_none()
|
||||
exchange_rate = rate_obj.rate if rate_obj else Decimal("1.0")
|
||||
|
||||
# 2. Kalkuláció
|
||||
amt_eur = Decimal(str(cost_in.amount_local)) / exchange_rate
|
||||
|
||||
# 3. Mentés az új AssetCost modellbe
|
||||
new_cost = AssetCost(
|
||||
asset_id=cost_in.asset_id,
|
||||
organization_id=cost_in.organization_id,
|
||||
driver_id=user_id,
|
||||
cost_type=cost_in.cost_type,
|
||||
amount_local=cost_in.amount_local,
|
||||
currency_local=cost_in.currency_local,
|
||||
amount_eur=amt_eur,
|
||||
exchange_rate_used=exchange_rate,
|
||||
mileage_at_cost=cost_in.mileage_at_cost,
|
||||
date=cost_in.date or datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(new_cost)
|
||||
|
||||
# 4. Telemetria szinkron
|
||||
if cost_in.mileage_at_cost:
|
||||
tel_stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == cost_in.asset_id)
|
||||
telemetry = (await db.execute(tel_stmt)).scalar_one_or_none()
|
||||
if telemetry and cost_in.mileage_at_cost > (telemetry.current_mileage or 0):
|
||||
telemetry.current_mileage = cost_in.mileage_at_cost
|
||||
|
||||
await db.commit()
|
||||
return new_cost
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
class ConfigService:
|
||||
"""
|
||||
MB 2.0 Alapvető konfigurációs szerviz.
|
||||
Kezeli az AI szolgáltatások (Ollama) dinamikus beállításait és promptjait.
|
||||
"""
|
||||
async def get_setting(self, db: AsyncSession, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Lekéri a kért beállítást.
|
||||
1. Megnézi a környezeti változókat (NAGYBETŰVEL).
|
||||
2. Ha nincs ilyen ENV, visszaadja a kódba égetett 'default' értéket.
|
||||
"""
|
||||
env_val = os.getenv(key.upper())
|
||||
if env_val is not None:
|
||||
# Automatikus típuskonverzió a default paraméter típusa alapján
|
||||
if isinstance(default, int): return int(env_val)
|
||||
if isinstance(default, float): return float(env_val)
|
||||
if isinstance(default, bool): return str(env_val).lower() in ('true', '1', 'yes')
|
||||
return env_val
|
||||
|
||||
return default
|
||||
|
||||
# A példány, amit a többi modul (pl. az auth_service, ai_service) importálni próbál
|
||||
config = ConfigService()
|
||||
144
backend/app/services/cost_service.py
Executable file
144
backend/app/services/cost_service.py
Executable file
@@ -0,0 +1,144 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/cost_service.py
|
||||
import uuid
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, func
|
||||
from app.models.asset import AssetCost, AssetTelemetry, ExchangeRate
|
||||
from app.services.gamification_service import GamificationService
|
||||
from app.services.config_service import config
|
||||
from app.schemas.asset_cost import AssetCostCreate
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CostService:
|
||||
"""
|
||||
Industrial Cost & Telemetry Service.
|
||||
Összeköti a pénzügyi kiadásokat, az OCR bizonylatokat és a jármű állapotát.
|
||||
"""
|
||||
|
||||
async def record_cost(self, db: AsyncSession, cost_in: AssetCostCreate, user_id: int):
|
||||
""" Teljes körű költségrögzítés: Konverzió + Telemetria + OCR + XP. """
|
||||
try:
|
||||
# 1. Dinamikus konfiguráció lekérése
|
||||
base_currency = await config.get_setting(db, "finance_base_currency", default="EUR")
|
||||
base_xp = await config.get_setting(db, "xp_per_cost_log", default=50)
|
||||
ocr_multiplier = await config.get_setting(db, "xp_multiplier_ocr_cost", default=1.5)
|
||||
|
||||
# 2. Intelligens Árfolyamkezelés
|
||||
exchange_rate = Decimal("1.0")
|
||||
if cost_in.currency_local != base_currency:
|
||||
rate_stmt = select(ExchangeRate).where(
|
||||
ExchangeRate.target_currency == cost_in.currency_local
|
||||
).order_by(desc(ExchangeRate.updated_at)).limit(1)
|
||||
rate_res = await db.execute(rate_stmt)
|
||||
rate_obj = rate_res.scalar_one_or_none()
|
||||
exchange_rate = rate_obj.rate if rate_obj else Decimal("1.0")
|
||||
|
||||
amt_base = Decimal(str(cost_in.amount_local)) / exchange_rate if exchange_rate > 0 else Decimal("0")
|
||||
|
||||
# 3. Költség rekord rögzítése (Kapcsolva a Robot 1 OCR dokumentumához)
|
||||
new_cost = AssetCost(
|
||||
asset_id=cost_in.asset_id,
|
||||
organization_id=cost_in.organization_id,
|
||||
driver_id=user_id,
|
||||
cost_type=cost_in.cost_type,
|
||||
amount_local=cost_in.amount_local,
|
||||
currency_local=cost_in.currency_local,
|
||||
amount_eur=amt_base,
|
||||
net_amount_local=cost_in.net_amount_local,
|
||||
vat_rate=cost_in.vat_rate,
|
||||
exchange_rate_used=exchange_rate,
|
||||
mileage_at_cost=cost_in.mileage_at_cost,
|
||||
date=cost_in.date or datetime.now(),
|
||||
# OCR Kapcsolat
|
||||
document_id=cost_in.document_id,
|
||||
is_ai_generated=cost_in.document_id is not None,
|
||||
data=cost_in.data or {}
|
||||
)
|
||||
db.add(new_cost)
|
||||
|
||||
# 4. Automatikus Telemetria (Kilométeróra frissítés)
|
||||
if cost_in.mileage_at_cost:
|
||||
await self._sync_telemetry(db, cost_in.asset_id, cost_in.mileage_at_cost)
|
||||
|
||||
# 5. Gamification (Értékesebb az adat, ha van róla fotó/OCR)
|
||||
final_xp = base_xp
|
||||
if new_cost.is_ai_generated:
|
||||
final_xp = int(base_xp * float(ocr_multiplier))
|
||||
|
||||
await GamificationService.award_points(
|
||||
db, user_id=user_id, amount=final_xp, reason=f"EXPENSE_LOG_{cost_in.cost_type}"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_cost)
|
||||
return new_cost
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"CostService Error: {e}")
|
||||
raise e
|
||||
|
||||
async def _sync_telemetry(self, db: AsyncSession, asset_id: int, mileage: int):
|
||||
""" Segédfüggvény: Biztonságos óraállás frissítés. """
|
||||
stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == asset_id)
|
||||
res = await db.execute(stmt)
|
||||
telemetry = res.scalar_one_or_none()
|
||||
|
||||
if telemetry:
|
||||
# Csak akkor frissítünk, ha az új érték nagyobb (nincs visszatekerés)
|
||||
if mileage > (telemetry.current_mileage or 0):
|
||||
telemetry.current_mileage = mileage
|
||||
telemetry.last_updated = datetime.now()
|
||||
else:
|
||||
db.add(AssetTelemetry(asset_id=asset_id, current_mileage=mileage))
|
||||
|
||||
async def get_asset_financial_summary(self, db: AsyncSession, asset_id: uuid.UUID) -> Dict[str, Any]:
|
||||
"""
|
||||
Dinamikus pénzügyi összesítő SQL szintű aggregációval.
|
||||
MB 2.0: Nem loopolunk Pythonban, a DB számol!
|
||||
"""
|
||||
# 1. Lekérjük az összesített adatokat kategóriánként (Local és EUR)
|
||||
stmt = (
|
||||
select(
|
||||
AssetCost.cost_type,
|
||||
func.sum(AssetCost.amount_local).label("total_local"),
|
||||
func.sum(AssetCost.amount_eur).label("total_eur"),
|
||||
func.count(AssetCost.id).label("transaction_count")
|
||||
)
|
||||
.where(AssetCost.asset_id == asset_id)
|
||||
.group_by(AssetCost.cost_type)
|
||||
)
|
||||
|
||||
res = await db.execute(stmt)
|
||||
rows = res.all()
|
||||
|
||||
summary = {
|
||||
"by_category": {},
|
||||
"grand_total_local": Decimal("0.0"),
|
||||
"grand_total_eur": Decimal("0.0"),
|
||||
"total_transactions": 0
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
cat = row.cost_type or "OTHER"
|
||||
summary["by_category"][cat] = {
|
||||
"local": float(row.total_local),
|
||||
"eur": float(row.total_eur),
|
||||
"count": row.transaction_count
|
||||
}
|
||||
summary["grand_total_local"] += row.total_local
|
||||
summary["grand_total_eur"] += row.total_eur
|
||||
summary["total_transactions"] += row.transaction_count
|
||||
|
||||
# Decimal konverzió a JSON-höz
|
||||
summary["grand_total_local"] = float(summary["grand_total_local"])
|
||||
summary["grand_total_eur"] = float(summary["grand_total_eur"])
|
||||
|
||||
return summary
|
||||
|
||||
cost_service = CostService()
|
||||
135
backend/app/services/document_service.py
Executable file
135
backend/app/services/document_service.py
Executable file
@@ -0,0 +1,135 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/document_service.py
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
from PIL import Image
|
||||
from uuid import uuid4
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import UploadFile, BackgroundTasks, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
|
||||
from app.models.document import Document
|
||||
from app.models.identity import User
|
||||
from app.services.config_service import config # 2.0 Dinamikus beállítások
|
||||
from app.workers.ocr.robot_1_ocr_processor import OCRRobot # Robot 1 hívása
|
||||
|
||||
logger = logging.getLogger("Document-Service-2.0")
|
||||
|
||||
class DocumentService:
|
||||
"""
|
||||
Document Service 2.0 - Admin-vezérelt Pipeline.
|
||||
Feladata: Tárolás, Optimalizálás, Kvótamanagement és Robot Trigger.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def process_upload(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
file: UploadFile,
|
||||
parent_type: str, # pl. "asset", "organization", "transfer"
|
||||
parent_id: str,
|
||||
doc_type: str, # pl. "invoice", "registration_card", "sale_contract"
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
try:
|
||||
# --- 1. ADMIN KVÓTA ELLENŐRZÉS ---
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one()
|
||||
|
||||
# Lekérjük a csomagnak megfelelő havi limitet (pl. Free: 1, Premium: 10)
|
||||
limits = await config.get_setting(db, "ocr_monthly_limit", default={"free": 1, "premium": 10, "vip": 100})
|
||||
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
allowed_ocr = limits.get(user_role, 1)
|
||||
|
||||
# Megnézzük a havi felhasználást
|
||||
now = datetime.now(timezone.utc)
|
||||
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
count_stmt = select(func.count(Document.id)).where(
|
||||
and_(
|
||||
Document.user_id == user_id,
|
||||
Document.created_at >= start_of_month
|
||||
)
|
||||
)
|
||||
used_count = (await db.execute(count_stmt)).scalar()
|
||||
|
||||
if used_count >= allowed_ocr:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Havi dokumentum limit túllépve ({allowed_ocr}). Válts csomagot a folytatáshoz!"
|
||||
)
|
||||
|
||||
# --- 2. DINAMIKUS TÁROLÁS ÉS OPTIMALIZÁLÁS ---
|
||||
file_uuid = str(uuid4())
|
||||
nas_base = await config.get_setting(db, "storage_nas_path", default="/mnt/nas/app_data")
|
||||
|
||||
# Útvonal sablonok az adminból (Pl. "vault/{parent_type}/{parent_id}")
|
||||
vault_dir = os.path.join(nas_base, parent_type, parent_id, "vault")
|
||||
thumb_dir = os.path.join(getattr(config, "STATIC_DIR", "static"), "previews", parent_type, parent_id)
|
||||
|
||||
os.makedirs(vault_dir, exist_ok=True)
|
||||
os.makedirs(thumb_dir, exist_ok=True)
|
||||
|
||||
content = await file.read()
|
||||
temp_path = f"/tmp/{file_uuid}_{file.filename}"
|
||||
with open(temp_path, "wb") as f: f.write(content)
|
||||
|
||||
# Kép feldolgozása PIL-lel
|
||||
img = Image.open(temp_path)
|
||||
|
||||
# Thumbnail generálás (SSD/Static területre)
|
||||
thumb_filename = f"{file_uuid}_thumb.webp"
|
||||
thumb_path = os.path.join(thumb_dir, thumb_filename)
|
||||
thumb_img = img.copy()
|
||||
thumb_img.thumbnail((300, 300))
|
||||
thumb_img.save(thumb_path, "WEBP", quality=80)
|
||||
|
||||
# Optimalizált eredeti (NAS / Vault területre)
|
||||
max_width = await config.get_setting(db, "img_max_width", default=1600)
|
||||
vault_filename = f"{file_uuid}.webp"
|
||||
vault_path = os.path.join(vault_dir, vault_filename)
|
||||
|
||||
if img.width > max_width:
|
||||
ratio = max_width / img.width
|
||||
img = img.resize((max_width, int(img.height * ratio)), Image.Resampling.LANCZOS)
|
||||
|
||||
img.save(vault_path, "WEBP", quality=85)
|
||||
|
||||
# --- 3. MENTÉS ---
|
||||
new_doc = Document(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
parent_type=parent_type,
|
||||
parent_id=parent_id,
|
||||
doc_type=doc_type,
|
||||
original_name=file.filename,
|
||||
file_hash=file_uuid,
|
||||
file_ext="webp",
|
||||
mime_type="image/webp",
|
||||
file_size=os.path.getsize(vault_path),
|
||||
has_thumbnail=True,
|
||||
thumbnail_path=f"/static/previews/{parent_type}/{parent_id}/{thumb_filename}",
|
||||
status="uploaded"
|
||||
)
|
||||
db.add(new_doc)
|
||||
await db.flush()
|
||||
|
||||
# --- 4. ROBOT TRIGGER (OCR AUTOMATIZMUS) ---
|
||||
# Megnézzük, hogy ez a típus (pl. invoice) igényel-e automatikus OCR-t
|
||||
auto_ocr_types = await config.get_setting(db, "ocr_auto_trigger_types", default=["invoice", "registration_card", "sale_contract"])
|
||||
|
||||
if doc_type in auto_ocr_types:
|
||||
# Robot 1 (OCR) sorba állítása háttérfolyamatként
|
||||
background_tasks.add_task(OCRRobot.process_document, db, new_doc.id)
|
||||
new_doc.status = "processing"
|
||||
logger.info(f"🤖 Robot 1 (OCR) riasztva: {new_doc.id}")
|
||||
|
||||
await db.commit()
|
||||
os.remove(temp_path)
|
||||
return new_doc
|
||||
|
||||
except Exception as e:
|
||||
if 'temp_path' in locals() and os.path.exists(temp_path): os.remove(temp_path)
|
||||
logger.error(f"Document Upload Error: {e}")
|
||||
raise e
|
||||
71
backend/app/services/dvla_service.py
Executable file
71
backend/app/services/dvla_service.py
Executable file
@@ -0,0 +1,71 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/dvla_service.py
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.config_service import config # 2.0 Dinamikus konfig
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger("DVLA-Service-2.2")
|
||||
|
||||
class DVLAService:
|
||||
"""
|
||||
Sentinel Master DVLA Service 2.2.
|
||||
Felelős a brit járműadatok lekéréséért a hivatalos állami API-n keresztül.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def get_vehicle_details(cls, db: AsyncSession, vrm: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
VRM (Vehicle Registration Mark) lekérdezése dinamikus admin beállításokkal.
|
||||
"""
|
||||
try:
|
||||
# 1. ADMIN BEÁLLÍTÁSOK LEKÉRÉSE
|
||||
# Megnézzük, engedélyezve van-e a szolgáltatás
|
||||
is_enabled = await config.get_setting(db, "dvla_api_enabled", default=True)
|
||||
if not is_enabled:
|
||||
logger.info("DVLA lekérdezés kihagyva (Admin által letiltva).")
|
||||
return None
|
||||
|
||||
api_url = await config.get_setting(
|
||||
db, "dvla_api_url",
|
||||
default="https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles"
|
||||
)
|
||||
api_key = await config.get_setting(db, "dvla_api_key")
|
||||
|
||||
if not api_key:
|
||||
logger.error("DVLA API kulcs hiányzik a system_parameters táblából!")
|
||||
return None
|
||||
|
||||
# 2. HITELESÍTÉS ÉS LEKÉRDEZÉS
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
# A DVLA szigorúan nagybetűs, szóköz nélküli rendszámot vár
|
||||
clean_vrm = vrm.replace(" ", "").upper().strip()
|
||||
payload = {"registrationNumber": clean_vrm}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(api_url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"✅ DVLA adat sikeresen lekérve: {clean_vrm}")
|
||||
return response.json()
|
||||
|
||||
elif response.status_code == 404:
|
||||
logger.warning(f"⚠️ Jármű nem található a DVLA adatbázisában: {clean_vrm}")
|
||||
return None
|
||||
|
||||
elif response.status_code == 429:
|
||||
logger.error("🚨 DVLA API hiba: Túl sok kérés (Rate Limit)!")
|
||||
return {"error": "rate_limited"}
|
||||
|
||||
else:
|
||||
logger.error(f"❌ DVLA API hiba ({response.status_code}): {response.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠️ DVLAService Kritikus Hiba: {e}")
|
||||
return None
|
||||
132
backend/app/services/email_manager.py
Executable file
132
backend/app/services/email_manager.py
Executable file
@@ -0,0 +1,132 @@
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import settings
|
||||
from app.core.i18n import locale_manager
|
||||
from app.services.config_service import config
|
||||
from app.db.session import AsyncSessionLocal # Szükséges a beállítások lekéréséhez
|
||||
|
||||
logger = logging.getLogger("Email-Manager-2.0")
|
||||
|
||||
class EmailManager:
|
||||
@staticmethod
|
||||
def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str:
|
||||
"""HTML sablon generálása a fordítási fájlok alapján (Megmaradt az eredeti logika)."""
|
||||
greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables)
|
||||
body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables)
|
||||
button_text = locale_manager.get(f"email.{template_key}_button", lang=lang)
|
||||
footer = locale_manager.get(f"email.{template_key}_footer", lang=lang)
|
||||
|
||||
link_fallback_text = locale_manager.get("email.link_fallback", lang=lang)
|
||||
|
||||
return f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; color: #333; line-height: 1.6;">
|
||||
<div style="max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 30px; border-radius: 10px;">
|
||||
<h2 style="color: #2c3e50;">{greeting}</h2>
|
||||
<p>{body}</p>
|
||||
<div style="text-align: center; margin: 40px 0;">
|
||||
<a href="{variables.get('link', '#')}"
|
||||
style="background-color: #3498db; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 16px;">
|
||||
{button_text}
|
||||
</a>
|
||||
</div>
|
||||
<p style="font-size: 0.85em; color: #777; word-break: break-all;">
|
||||
{link_fallback_text}<br>
|
||||
<a href="{variables.get('link')}" style="color: #3498db;">{variables.get('link')}</a>
|
||||
</p>
|
||||
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
|
||||
<p style="font-size: 0.8em; color: #999; text-align: center;">{footer}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def send_email(recipient: str, template_key: str, variables: dict, lang: str = "hu", db: Optional[AsyncSession] = None):
|
||||
"""
|
||||
E-mail küldése admin-vezérelt szolgáltatóval (SendGrid vagy SMTP).
|
||||
"""
|
||||
# 1. Belső session kezelés, ha kívülről nem kaptunk (pl. háttérfolyamatoknál)
|
||||
session_internal = False
|
||||
if db is None:
|
||||
db = AsyncSessionLocal()
|
||||
session_internal = True
|
||||
|
||||
try:
|
||||
# 2. Dinamikus beállítások lekérése az adatbázisból (Admin 2.0)
|
||||
provider = await config.get_setting(db, "email_provider", default="disabled")
|
||||
from_email = await config.get_setting(db, "emails_from_email", default="noreply@profibot.hu")
|
||||
from_name = await config.get_setting(db, "emails_from_name", default="Sentinel System")
|
||||
|
||||
if provider == "disabled":
|
||||
logger.info(f"Email küldés letiltva (Admin config). Cél: {recipient}")
|
||||
return
|
||||
|
||||
html = EmailManager._get_html_template(template_key, variables, lang)
|
||||
subject = locale_manager.get(f"email.{template_key}_subject", lang=lang)
|
||||
|
||||
# 3. KÜLDÉSI LOGIKA VÁLASZTÁSA
|
||||
if provider == "sendgrid":
|
||||
api_key = await config.get_setting(db, "sendgrid_api_key")
|
||||
if api_key:
|
||||
return await EmailManager._send_via_sendgrid(api_key, from_email, from_name, recipient, subject, html)
|
||||
logger.warning("SendGrid szolgáltató kiválasztva, de nincs API kulcs!")
|
||||
|
||||
# Fallback vagy közvetlen SMTP
|
||||
smtp_cfg = await config.get_setting(db, "smtp_config", default={
|
||||
"host": "localhost", "port": 587, "user": "", "pass": "", "tls": True
|
||||
})
|
||||
return await EmailManager._send_via_smtp(smtp_cfg, from_email, from_name, recipient, subject, html)
|
||||
|
||||
finally:
|
||||
if session_internal:
|
||||
await db.close()
|
||||
|
||||
@staticmethod
|
||||
async def _send_via_sendgrid(api_key: str, from_email: str, from_name: str, recipient: str, subject: str, html: str):
|
||||
try:
|
||||
from sendgrid import SendGridAPIClient
|
||||
from sendgrid.helpers.mail import Mail
|
||||
|
||||
message = Mail(
|
||||
from_email=(from_email, from_name),
|
||||
to_emails=recipient,
|
||||
subject=subject,
|
||||
html_content=html
|
||||
)
|
||||
sg = SendGridAPIClient(api_key)
|
||||
response = sg.send(message)
|
||||
logger.info(f"SendGrid siker: {response.status_code} -> {recipient}")
|
||||
return {"status": "success", "provider": "sendgrid"}
|
||||
except Exception as e:
|
||||
logger.error(f"SendGrid hiba: {str(e)}")
|
||||
return {"status": "error", "message": "SendGrid failed"}
|
||||
|
||||
@staticmethod
|
||||
async def _send_via_smtp(cfg: dict, from_email: str, from_name: str, recipient: str, subject: str, html: str):
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = f"{from_name} <{from_email}>"
|
||||
msg["To"] = recipient
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=15) as server:
|
||||
if cfg.get("tls", True):
|
||||
server.starttls()
|
||||
if cfg.get("user") and cfg.get("pass"):
|
||||
server.login(cfg["user"], cfg["pass"])
|
||||
server.send_message(msg)
|
||||
|
||||
logger.info(f"SMTP siker -> {recipient}")
|
||||
return {"status": "success", "provider": "smtp"}
|
||||
except Exception as e:
|
||||
logger.error(f"SMTP hiba: {str(e)}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
email_manager = EmailManager()
|
||||
135
backend/app/services/fleet_service.py
Executable file
135
backend/app/services/fleet_service.py
Executable file
@@ -0,0 +1,135 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/fleet_service.py
|
||||
import logging
|
||||
from uuid import UUID
|
||||
from typing import Optional, Dict, Any
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.asset import Asset, AssetEvent, AssetCost, AssetTelemetry
|
||||
from app.models.social import ServiceProvider, ModerationStatus
|
||||
from app.schemas.fleet import EventCreate, TCOStats
|
||||
from app.services.gamification_service import gamification_service
|
||||
from app.services.config_service import config # 2.0 Dinamikus konfig
|
||||
|
||||
logger = logging.getLogger("Fleet-Service-2.2")
|
||||
|
||||
class FleetService:
|
||||
"""
|
||||
Sentinel Master Fleet Service 2.2.
|
||||
Kezeli a járműflotta eseményeit és a TCO elemzéseket admin-vezérelt szabályokkal.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def add_vehicle_event(db: AsyncSession, asset_id: UUID, event_data: EventCreate, user_id: int):
|
||||
"""
|
||||
Esemény rögzítése dinamikus jutalmazással és anomália figyeléssel.
|
||||
"""
|
||||
try:
|
||||
# 1. Asset és Telemetria betöltése
|
||||
stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.telemetry))
|
||||
res = await db.execute(stmt)
|
||||
asset = res.scalar_one_or_none()
|
||||
if not asset: return None
|
||||
|
||||
# 2. ADMIN KONFIGURÁCIÓ LEKÉRÉSE (Hierarchikus: User > Region > Global)
|
||||
# Lekérjük az eseménytípushoz tartozó jutalmakat
|
||||
event_rewards = await config.get_setting(
|
||||
db,
|
||||
"FLEET_EVENT_REWARDS",
|
||||
scope_level="user",
|
||||
scope_id=str(user_id),
|
||||
default={
|
||||
"refuel": {"xp": 30, "social": 5},
|
||||
"service": {"xp": 100, "social": 20},
|
||||
"inspection": {"xp": 50, "social": 10},
|
||||
"default": {"xp": 20, "social": 2}
|
||||
}
|
||||
)
|
||||
|
||||
# 3. SZOLGÁLTATÓ KEZELÉSE
|
||||
provider_id = event_data.provider_id
|
||||
if not event_data.is_diy and event_data.provider_name and not provider_id:
|
||||
p_stmt = select(ServiceProvider).where(func.lower(ServiceProvider.name) == event_data.provider_name.lower())
|
||||
existing = (await db.execute(p_stmt)).scalar_one_or_none()
|
||||
if existing:
|
||||
provider_id = existing.id
|
||||
else:
|
||||
new_p = ServiceProvider(
|
||||
name=event_data.provider_name,
|
||||
added_by_user_id=user_id,
|
||||
status=ModerationStatus.pending
|
||||
)
|
||||
db.add(new_p)
|
||||
await db.flush()
|
||||
provider_id = new_p.id
|
||||
|
||||
# 4. ANOMÁLIA DETEKCIÓ (Admin-vezérelt küszöbökkel)
|
||||
current_mileage = asset.telemetry.current_mileage if asset.telemetry else 0
|
||||
is_odometer_anomaly = event_data.odometer_value < current_mileage
|
||||
|
||||
# 5. ESEMÉNY RÖGZÍTÉSE
|
||||
new_event = AssetEvent(
|
||||
asset_id=asset_id,
|
||||
event_type=event_data.event_type,
|
||||
recorded_mileage=event_data.odometer_value,
|
||||
provider_id=provider_id,
|
||||
is_anomaly=is_odometer_anomaly,
|
||||
data=event_data.model_dump(exclude={"provider_id", "provider_name"})
|
||||
)
|
||||
db.add(new_event)
|
||||
|
||||
# 6. DINAMIKUS GAMIFIKÁCIÓ
|
||||
# Kikeresjük a konkrét eseménytípushoz tartozó pontokat
|
||||
rewards = event_rewards.get(event_data.event_type, event_rewards["default"])
|
||||
|
||||
await gamification_service.process_activity(
|
||||
db,
|
||||
user_id,
|
||||
xp_amount=rewards["xp"],
|
||||
social_amount=rewards["social"],
|
||||
reason=f"FLEET_EVENT_{event_data.event_type.upper()}"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return new_event
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Fleet Event Error: {e}")
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
async def calculate_tco(db: AsyncSession, asset_id: UUID) -> TCOStats:
|
||||
"""
|
||||
TCO számítás dinamikus pénznemkezeléssel és KM-alapú költséganalízissel.
|
||||
"""
|
||||
# 1. Admin beállítások (Pl. alapértelmezett pénznem a riportokhoz)
|
||||
report_currency = await config.get_setting(db, "finance_base_currency", default="EUR")
|
||||
|
||||
# 2. Költségek összesítése kategóriánként
|
||||
result = await db.execute(
|
||||
select(AssetCost.cost_type, func.sum(AssetCost.amount_eur))
|
||||
.where(AssetCost.asset_id == asset_id)
|
||||
.group_by(AssetCost.cost_type)
|
||||
)
|
||||
|
||||
breakdown = {row[0]: float(row[1]) for row in result.all()}
|
||||
total_eur = sum(breakdown.values())
|
||||
|
||||
# 3. KM alapú költség (Telemetria bevonása)
|
||||
telemetry_stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == asset_id)
|
||||
telemetry = (await db.execute(telemetry_stmt)).scalar_one_or_none()
|
||||
|
||||
mileage = telemetry.current_mileage if telemetry and telemetry.current_mileage > 0 else 1
|
||||
cost_per_km = total_eur / mileage
|
||||
|
||||
return TCOStats(
|
||||
asset_id=asset_id,
|
||||
total_cost_eur=total_eur,
|
||||
breakdown=breakdown,
|
||||
cost_per_km=round(cost_per_km, 4),
|
||||
currency=report_currency
|
||||
)
|
||||
153
backend/app/services/gamification_service.py
Executable file
153
backend/app/services/gamification_service.py
Executable file
@@ -0,0 +1,153 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/gamification_service.py
|
||||
import logging
|
||||
import math
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from app.models.gamification import UserStats, PointsLedger, UserBadge, Badge
|
||||
from app.models.identity import User, Wallet
|
||||
from app.models.audit import FinancialLedger
|
||||
from app.services.config_service import config # 2.0 Központi konfigurátor
|
||||
|
||||
logger = logging.getLogger("Gamification-Service-2.0")
|
||||
|
||||
class GamificationService:
|
||||
"""
|
||||
Gamification Service 2.0 - A 'Jövevény' lelke.
|
||||
Felelős a pontozásért, szintekért, büntetésekért és a jutalom-kreditekért.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0):
|
||||
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz. """
|
||||
service = GamificationService()
|
||||
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason)
|
||||
|
||||
async def process_activity(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
xp_amount: int,
|
||||
social_amount: int,
|
||||
reason: str,
|
||||
is_penalty: bool = False
|
||||
):
|
||||
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés. """
|
||||
try:
|
||||
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
|
||||
# Minden paraméter az admin felületről módosítható JSON-ként
|
||||
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default={
|
||||
"xp_logic": {"base_xp": 500, "exponent": 1.5},
|
||||
"penalty_logic": {
|
||||
"recovery_rate": 0.5,
|
||||
"thresholds": {"level_1": 100, "level_2": 500, "level_3": 1000},
|
||||
"multipliers": {"L0": 1.0, "L1": 0.5, "L2": 0.1, "L3": 0.0}
|
||||
},
|
||||
"conversion_logic": {"social_to_credit_rate": 100},
|
||||
"level_rewards": {"credits_per_10_levels": 50}
|
||||
})
|
||||
|
||||
# 2. FELHASZNÁLÓ ÉS STATISZTIKA ELLENŐRZÉSE
|
||||
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
|
||||
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
|
||||
|
||||
if not stats:
|
||||
stats = UserStats(user_id=user_id, total_xp=0, current_level=1, penalty_points=0)
|
||||
db.add(stats)
|
||||
await db.flush()
|
||||
|
||||
# 3. BÜNTETŐ LOGIKA (Ha negatív esemény történik)
|
||||
if is_penalty:
|
||||
return await self._apply_penalty(db, stats, xp_amount, reason, cfg)
|
||||
|
||||
# 4. SZORZÓK ALKALMAZÁSA (Büntetés alatt állók 'bírsága')
|
||||
multiplier = await self._calculate_multiplier(stats, cfg)
|
||||
if multiplier <= 0:
|
||||
logger.warning(f"User {user_id} pontszerzése blokkolva a büntetések miatt.")
|
||||
return stats
|
||||
|
||||
# 5. XP SZÁMÍTÁS ÉS SZINTLÉPÉS
|
||||
final_xp = int(xp_amount * multiplier)
|
||||
if final_xp > 0:
|
||||
stats.total_xp += final_xp
|
||||
# Büntetés ledolgozás (Recovery)
|
||||
if stats.penalty_points > 0:
|
||||
recovery = int(final_xp * cfg["penalty_logic"]["recovery_rate"])
|
||||
stats.penalty_points = max(0, stats.penalty_points - recovery)
|
||||
|
||||
# Új szint számítás hatványfüggvénnyel:
|
||||
# $Level = \sqrt[exponent]{\frac{XP}{Base}} + 1$
|
||||
xp_cfg = cfg["xp_logic"]
|
||||
new_level = int((stats.total_xp / xp_cfg["base_xp"]) ** (1 / xp_cfg["exponent"])) + 1
|
||||
|
||||
if new_level > stats.current_level:
|
||||
await self._handle_level_up(db, user_id, stats.current_level, new_level, cfg)
|
||||
stats.current_level = new_level
|
||||
|
||||
# 6. SOCIAL PONT ÉS KREDIT KONVERZIÓ
|
||||
final_social = int(social_amount * multiplier)
|
||||
if final_social > 0:
|
||||
stats.social_points += final_social
|
||||
rate = cfg["conversion_logic"]["social_to_credit_rate"]
|
||||
|
||||
if stats.social_points >= rate:
|
||||
credits_to_add = stats.social_points // rate
|
||||
stats.social_points %= rate # A maradék pont megmarad
|
||||
await self._add_earned_credits(db, user_id, credits_to_add, "SOCIAL_ACTIVITY_CONVERSION")
|
||||
|
||||
# 7. NAPLÓZÁS
|
||||
db.add(PointsLedger(user_id=user_id, points=final_xp, reason=reason))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(stats)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Gamification Error for user {user_id}: {e}")
|
||||
raise e
|
||||
|
||||
# --- PRIVÁT SEGÉDFÜGGVÉNYEK ---
|
||||
|
||||
async def _apply_penalty(self, db: AsyncSession, stats: UserStats, amount: int, reason: str, cfg: dict):
|
||||
"""Büntetőpontok hozzáadása és korlátozási szintek emelése."""
|
||||
stats.penalty_points += amount
|
||||
th = cfg["penalty_logic"]["thresholds"]
|
||||
|
||||
if stats.penalty_points >= th["level_3"]: stats.restriction_level = 3
|
||||
elif stats.penalty_points >= th["level_2"]: stats.restriction_level = 2
|
||||
elif stats.penalty_points >= th["level_1"]: stats.restriction_level = 1
|
||||
|
||||
db.add(PointsLedger(user_id=stats.user_id, points=0, penalty_change=amount, reason=f"🔴 PENALTY: {reason}"))
|
||||
await db.commit()
|
||||
return stats
|
||||
|
||||
async def _calculate_multiplier(self, stats: UserStats, cfg: dict) -> float:
|
||||
"""Kiszámolja a szorzót a jelenlegi büntetési szint alapján."""
|
||||
m = cfg["penalty_logic"]["multipliers"]
|
||||
if stats.restriction_level == 3: return m["L3"]
|
||||
if stats.restriction_level == 2: return m["L2"]
|
||||
if stats.restriction_level == 1: return m["L1"]
|
||||
return m["L0"]
|
||||
|
||||
async def _handle_level_up(self, db: AsyncSession, user_id: int, old_lvl: int, new_lvl: int, cfg: dict):
|
||||
"""Szintlépési jutalmak (pl. minden 10. szintnél kredit)."""
|
||||
logger.info(f"✨ Level Up: User {user_id} ({old_lvl} -> {new_lvl})")
|
||||
if new_lvl % 10 == 0:
|
||||
reward = cfg["level_rewards"]["credits_per_10_levels"]
|
||||
await self._add_earned_credits(db, user_id, reward, f"LEVEL_{new_lvl}_REWARD")
|
||||
|
||||
async def _add_earned_credits(self, db: AsyncSession, user_id: int, amount: int, reason: str):
|
||||
"""Kredit jóváírása a Wallet-ben és a pénzügyi naplóban."""
|
||||
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id)
|
||||
wallet = (await db.execute(wallet_stmt)).scalar_one_or_none()
|
||||
if wallet:
|
||||
wallet.earned_credits += Decimal(str(amount))
|
||||
db.add(FinancialLedger(
|
||||
user_id=user_id,
|
||||
amount=float(amount),
|
||||
transaction_type="GAMIFICATION_CREDIT",
|
||||
details={"reason": reason}
|
||||
))
|
||||
|
||||
gamification_service = GamificationService()
|
||||
155
backend/app/services/geo_service.py
Executable file
155
backend/app/services/geo_service.py
Executable file
@@ -0,0 +1,155 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/geo_service.py
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text, select
|
||||
|
||||
from app.services.config_service import config # 2.0 Dinamikus konfig
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
logger = logging.getLogger("Geo-Service-2.2")
|
||||
|
||||
class GeoService:
|
||||
"""
|
||||
Sentinel Master GeoService 2.2.
|
||||
Felelős a címek normalizálásáért, a szótárak építéséért és a téradatokért.
|
||||
Minden paraméter (ország, sablon, limit) adminból vezérelt.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def get_street_suggestions(db: AsyncSession, zip_code: str, q: str, user_id: Optional[int] = None) -> List[str]:
|
||||
"""
|
||||
Autocomplete támogatás az utcákhoz.
|
||||
A limitet és a keresési logikát az adminból vesszük.
|
||||
"""
|
||||
# 1. Admin beállítások lekérése
|
||||
search_limit = await config.get_setting(db, "GEO_SUGGESTION_LIMIT", default=10)
|
||||
|
||||
query = text("""
|
||||
SELECT DISTINCT s.name
|
||||
FROM data.geo_streets s
|
||||
JOIN data.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
|
||||
""")
|
||||
try:
|
||||
res = await db.execute(query, {"zip": zip_code, "q": f"{q}%", "limit": search_limit})
|
||||
return [row[0] for row in res.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Street Suggestion Error: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
async def get_or_create_full_address(
|
||||
db: AsyncSession,
|
||||
zip_code: str,
|
||||
city: str,
|
||||
street_name: str,
|
||||
street_type: str,
|
||||
house_number: str,
|
||||
stairwell: Optional[str] = None,
|
||||
floor: Optional[str] = None,
|
||||
door: Optional[str] = None,
|
||||
parcel_id: Optional[str] = None,
|
||||
user_id: Optional[int] = None # A régió-alapú felülbíráláshoz
|
||||
) -> uuid.UUID:
|
||||
"""
|
||||
Hibrid címrögzítés atomizált mezőkkel.
|
||||
A cím generálásának módja (sablonja) régió- vagy felhasználó-specifikus lehet.
|
||||
"""
|
||||
try:
|
||||
# 1. ADMIN BEÁLLÍTÁSOK LEKÉRÉSE (Hierarchikus: User > Region > Global)
|
||||
# Országkód (pl. HU, AT, DE)
|
||||
default_country = await config.get_setting(
|
||||
db, "geo_default_country_code",
|
||||
scope_level="user" if user_id else "global",
|
||||
scope_id=str(user_id) if user_id else None,
|
||||
default="HU"
|
||||
)
|
||||
|
||||
# Címformázási sablon (pl. "{zip} {city}, {street} {type} {number}")
|
||||
address_template = await config.get_setting(
|
||||
db, "GEO_ADDRESS_FORMAT_TEMPLATE",
|
||||
default="{zip} {city}, {street} {type} {number}."
|
||||
)
|
||||
|
||||
# 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)
|
||||
VALUES (:z, :c, :cc)
|
||||
ON CONFLICT (country_code, zip_code, city) DO UPDATE SET city = EXCLUDED.city
|
||||
RETURNING id
|
||||
""")
|
||||
zip_res = await db.execute(zip_id_query, {"z": zip_code, "c": city, "cc": default_country})
|
||||
zip_id = zip_res.scalar()
|
||||
|
||||
# 3. Utca szótár frissítése
|
||||
await db.execute(text("""
|
||||
INSERT INTO data.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)
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
"""), {"n": street_type.lower()})
|
||||
|
||||
# 5. SZÖVEGES CÍM GENERÁLÁSA SABLON ALAPJÁN (2.2 Újdonság)
|
||||
# Megformázzuk az alapcímet az admin sablon szerint
|
||||
full_text = address_template.format(
|
||||
zip=zip_code,
|
||||
city=city,
|
||||
street=street_name,
|
||||
type=street_type,
|
||||
number=house_number
|
||||
)
|
||||
|
||||
# Hozzáadjuk az atomizált kiegészítőket, ha vannak
|
||||
if stairwell: full_text += f" {stairwell}. lph."
|
||||
if floor: full_text += f" {floor}. em."
|
||||
if door: full_text += f" {door}. ajtó"
|
||||
|
||||
# 6. Központi Address rekord rögzítése vagy lekérése
|
||||
address_query = text("""
|
||||
INSERT INTO data.addresses (
|
||||
postal_code_id, street_name, street_type, house_number,
|
||||
stairwell, floor, door, parcel_id, full_address_text
|
||||
)
|
||||
VALUES (:zid, :sn, :st, :hn, :sw, :fl, :dr, :pid, :txt)
|
||||
ON CONFLICT (postal_code_id, street_name, street_type, house_number, stairwell, floor, door)
|
||||
DO NOTHING
|
||||
RETURNING id
|
||||
""")
|
||||
|
||||
params = {
|
||||
"zid": zip_id, "sn": street_name, "st": street_type,
|
||||
"hn": house_number, "sw": stairwell, "fl": floor,
|
||||
"dr": door, "pid": parcel_id, "txt": full_text
|
||||
}
|
||||
|
||||
res = await db.execute(address_query, params)
|
||||
addr_id = res.scalar()
|
||||
|
||||
# 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
|
||||
WHERE postal_code_id = :zid
|
||||
AND street_name = :sn
|
||||
AND street_type = :st
|
||||
AND house_number = :hn
|
||||
AND (stairwell IS NOT DISTINCT FROM :sw)
|
||||
AND (floor IS NOT DISTINCT FROM :fl)
|
||||
AND (door IS NOT DISTINCT FROM :dr)
|
||||
LIMIT 1
|
||||
""")
|
||||
lookup_res = await db.execute(lookup_query, params)
|
||||
addr_id = lookup_res.scalar()
|
||||
|
||||
return addr_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"GeoService Critical Error: {str(e)}")
|
||||
raise ValueError(f"Súlyos hiba a cím normalizálása során. Admin/Séma ellenőrzése javasolt.")
|
||||
38
backend/app/services/image_processor.py
Executable file
38
backend/app/services/image_processor.py
Executable file
@@ -0,0 +1,38 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/image_processor.py
|
||||
import cv2
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
|
||||
class DocumentImageProcessor:
|
||||
""" Saját képtisztító pipeline Robot 3 OCR számára. """
|
||||
|
||||
@staticmethod
|
||||
def process_for_ocr(image_bytes: bytes) -> Optional[bytes]:
|
||||
if not image_bytes: return None
|
||||
try:
|
||||
nparr = np.frombuffer(image_bytes, np.uint8)
|
||||
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
if img is None: return None
|
||||
|
||||
# 1. Előkészítés (Szürkeárnyalat + Felskálázás)
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
if gray.shape[1] < 1200:
|
||||
gray = cv2.resize(gray, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
|
||||
|
||||
# 2. Kontraszt dúsítás (CLAHE)
|
||||
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||||
contrast = clahe.apply(gray)
|
||||
|
||||
# 3. Adaptív Binarizálás (Fekete-fehér szöveg kiemelés)
|
||||
blur = cv2.GaussianBlur(contrast, (3, 3), 0)
|
||||
thresh = cv2.adaptiveThreshold(
|
||||
blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY, 11, 2
|
||||
)
|
||||
|
||||
success, encoded_image = cv2.imencode('.png', thresh)
|
||||
return encoded_image.tobytes() if success else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"OpenCV Feldolgozási hiba: {e}")
|
||||
return None
|
||||
106
backend/app/services/maintenance_service.py
Executable file
106
backend/app/services/maintenance_service.py
Executable file
@@ -0,0 +1,106 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/maintenance_service.py
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from app.models.asset import Asset, AssetTelemetry
|
||||
from app.services.config_service import config # 2.0 Dinamikus konfig
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger("Maintenance-Service-2.0")
|
||||
|
||||
class MaintenanceService:
|
||||
"""
|
||||
Sentinel Master Maintenance Service 2.0.
|
||||
Felelős a rendszer tisztításáért és a prediktív karbantartási riasztásokért.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def cleanup_old_files(db: AsyncSession):
|
||||
"""
|
||||
Admin-vezérelt NAS takarítás.
|
||||
A megőrzési időt (napokban) az adatbázisból veszi.
|
||||
"""
|
||||
try:
|
||||
storage_path = await config.get_setting(db, "storage_nas_path", default="/mnt/nas/app_data")
|
||||
retention_days = await config.get_setting(db, "storage_retention_days", default=365)
|
||||
|
||||
limit = datetime.now() - timedelta(days=int(retention_days))
|
||||
deleted_count = 0
|
||||
|
||||
if not os.path.exists(storage_path):
|
||||
logger.warning(f"A tárolási útvonal nem található: {storage_path}")
|
||||
return 0
|
||||
|
||||
for root, dirs, files in os.walk(storage_path):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
||||
|
||||
if file_time < limit:
|
||||
os.remove(file_path)
|
||||
deleted_count += 1
|
||||
|
||||
logger.info(f"🗑️ NAS takarítás kész. Törölve: {deleted_count} lejárt fájl.")
|
||||
return deleted_count
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a takarítás során: {e}")
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
async def check_maintenance_intervals(db: AsyncSession):
|
||||
"""
|
||||
Prediktív karbantartás: Összeveti a Robot 3 gyári adatait a valós futásteljesítménnyel.
|
||||
Ha egy autó közeledik az olajcseréhez (pl. 1000 km-en belül), riasztást generál.
|
||||
"""
|
||||
try:
|
||||
# Admin beállítás: hány km-rel a szerviz előtt szóljunk?
|
||||
km_threshold = await config.get_setting(db, "maint_km_alert_threshold", default=1000)
|
||||
|
||||
# Lekérjük az összes autót a telemetriával együtt
|
||||
stmt = select(Asset, AssetTelemetry).join(AssetTelemetry).where(Asset.status == "active")
|
||||
result = await db.execute(stmt)
|
||||
|
||||
alerts_generated = 0
|
||||
for asset, telemetry in result.all():
|
||||
# A Robot 3 által feltöltött gyári adat (pl. 15.000 km)
|
||||
interval = asset.factory_data.get("oil_change_km") if asset.factory_data else None
|
||||
last_service_km = asset.factory_data.get("last_service_km", 0)
|
||||
|
||||
if interval and telemetry.current_mileage:
|
||||
next_service_due = last_service_km + interval
|
||||
remaining_km = next_service_due - telemetry.current_mileage
|
||||
|
||||
if 0 <= remaining_km <= int(km_threshold):
|
||||
# Értesítés küldése a Notification Centerbe és Emailben
|
||||
await NotificationService.send_direct_notification(
|
||||
db,
|
||||
user_id=asset.owner_id,
|
||||
message_key="maintenance_due",
|
||||
variables={
|
||||
"vehicle": f"{asset.license_plate}",
|
||||
"type": "Olajcsere",
|
||||
"remaining": remaining_km
|
||||
}
|
||||
)
|
||||
alerts_generated += 1
|
||||
|
||||
return alerts_generated
|
||||
except Exception as e:
|
||||
logger.error(f"Karbantartás ellenőrzési hiba: {e}")
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
async def delete_validated_evidence(db: AsyncSession, document_id: str):
|
||||
"""
|
||||
Validáció utáni képkezelés.
|
||||
Az adminban állítható, hogy töröljük-e a bizonyítékot a hitelesítés után.
|
||||
"""
|
||||
should_delete = await config.get_setting(db, "storage_delete_after_validation", default=False)
|
||||
|
||||
if should_delete:
|
||||
# Itt a Document modell alapján megkeressük a fájlt és töröljük
|
||||
# (A biztonság kedvéért naplózzuk a Sentinelbe)
|
||||
pass
|
||||
35
backend/app/services/matching_service.py
Executable file
35
backend/app/services/matching_service.py
Executable file
@@ -0,0 +1,35 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/matching_service.py
|
||||
from typing import List, Dict, Any
|
||||
from app.services.config_service import config
|
||||
|
||||
class MatchingService:
|
||||
@staticmethod
|
||||
async def rank_services(services: List[Dict[str, Any]], org_id: int = None) -> List[Dict[str, Any]]:
|
||||
""" Szolgáltatók rangsorolása dinamikus Sentinel paraméterek alapján. """
|
||||
|
||||
# JAVÍTVA: Hierarchikus paraméterek lekérése
|
||||
w_dist = float(await config.get_setting('weight_distance', org_id=org_id, default=0.5))
|
||||
w_rate = float(await config.get_setting('weight_rating', org_id=org_id, default=0.5))
|
||||
b_gold = float(await config.get_setting('bonus_gold_service', org_id=org_id, default=500))
|
||||
|
||||
ranked_list = []
|
||||
for s in services:
|
||||
# Távolság pont (közelebb = több pont)
|
||||
dist = s.get('distance', 1.0)
|
||||
p_dist = 100 / (dist + 1)
|
||||
|
||||
# Értékelés pont (0-5 csillag -> 0-100 pont)
|
||||
p_rate = s.get('rating', 0.0) * 20
|
||||
|
||||
# Bónusz a kiemelt (Gold) partnereknek
|
||||
tier_bonus = b_gold if s.get('tier') == 'gold' else 0
|
||||
|
||||
# Összesített pontszám
|
||||
total_score = (p_dist * w_dist) + (p_rate * w_rate) + tier_bonus
|
||||
|
||||
s['total_score'] = round(total_score, 2)
|
||||
ranked_list.append(s)
|
||||
|
||||
return sorted(ranked_list, key=lambda x: x['total_score'], reverse=True)
|
||||
|
||||
matching_service = MatchingService()
|
||||
45
backend/app/services/media_service.py
Executable file
45
backend/app/services/media_service.py
Executable file
@@ -0,0 +1,45 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/media_service.py
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import TAGS, GPSTAGS
|
||||
import logging
|
||||
from typing import Tuple, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MediaService:
|
||||
@staticmethod
|
||||
def _convert_to_degrees(value) -> float:
|
||||
""" EXIF racionális koordináták konvertálása tizedes fokká. """
|
||||
try:
|
||||
d = float(value[0])
|
||||
m = float(value[1])
|
||||
s = float(value[2])
|
||||
return d + (m / 60.0) + (s / 3600.0)
|
||||
except (IndexError, ZeroDivisionError, TypeError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def extract_gps_info(cls, file_path: str) -> Optional[Tuple[float, float]]:
|
||||
""" GPS koordináták kinyerése a kép metaadataiból (Robot Hunt alapja). """
|
||||
try:
|
||||
with Image.open(file_path) as image:
|
||||
exif = image._getexif()
|
||||
if not exif: return None
|
||||
|
||||
gps_info = {}
|
||||
for tag, value in exif.items():
|
||||
if TAGS.get(tag) == "GPSInfo":
|
||||
for t in value:
|
||||
gps_info[GPSTAGS.get(t, t)] = value[t]
|
||||
|
||||
if 'GPSLatitude' in gps_info and 'GPSLongitude' in gps_info:
|
||||
lat = cls._convert_to_degrees(gps_info['GPSLatitude'])
|
||||
if gps_info.get('GPSLatitudeRef') != "N": lat = -lat
|
||||
|
||||
lon = cls._convert_to_degrees(gps_info['GPSLongitude'])
|
||||
if gps_info.get('GPSLongitudeRef') != "E": lon = -lon
|
||||
|
||||
return lat, lon
|
||||
except Exception as e:
|
||||
logger.warning(f"EXIF kiolvasási hiba ({file_path}): {e}")
|
||||
return None
|
||||
150
backend/app/services/notification_service.py
Executable file
150
backend/app/services/notification_service.py
Executable file
@@ -0,0 +1,150 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/notification_service.py
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.models.identity import User
|
||||
from app.models.asset import Asset
|
||||
from app.models.organization import Organization
|
||||
from app.models.system import InternalNotification
|
||||
from app.services.email_manager import email_manager
|
||||
from app.services.config_service import config
|
||||
|
||||
logger = logging.getLogger("Notification-Service-2.2")
|
||||
|
||||
class NotificationService:
|
||||
"""
|
||||
Sentinel Master Notification Service 2.2 - Fully Admin-Configurable.
|
||||
Nincs fix kód: minden típus (biztosítás, műszaki, okmány) a DB-ből vezérelt.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def send_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message: str,
|
||||
category: str = "info",
|
||||
priority: str = "medium",
|
||||
data: dict = None,
|
||||
send_email: bool = True,
|
||||
email_template: str = None,
|
||||
email_vars: dict = None
|
||||
):
|
||||
""" Univerzális küldő: Belső Dashboard + Email. """
|
||||
new_notif = InternalNotification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
message=message,
|
||||
category=category,
|
||||
priority=priority,
|
||||
data=data or {}
|
||||
)
|
||||
db.add(new_notif)
|
||||
|
||||
if send_email and email_template and email_vars:
|
||||
await email_manager.send_email(
|
||||
db=db,
|
||||
recipient=email_vars.get("recipient"),
|
||||
template_key=email_template,
|
||||
variables=email_vars,
|
||||
lang=email_vars.get("lang", "hu")
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@staticmethod
|
||||
async def check_expiring_documents(db: AsyncSession):
|
||||
"""
|
||||
Dinamikus lejárat-figyelő: Típusonkénti naptárak az adminból.
|
||||
"""
|
||||
try:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
# 1. Lekérjük az összes aktív járművet és a tulajdonosaikat
|
||||
stmt = (
|
||||
select(Asset, User)
|
||||
.join(Organization, Asset.current_organization_id == Organization.id)
|
||||
.join(User, Organization.owner_id == User.id)
|
||||
.where(Asset.status == "active")
|
||||
.options(joinedload(Asset.catalog))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
notifications_sent = 0
|
||||
|
||||
for asset, user in result.all():
|
||||
# 2. DINAMIKUS MÁTRIX LEKÉRÉSE (Hierarchikus: User > Region > Global)
|
||||
# Az adminban így van tárolva: {"insurance": [45, 30, 7, 0], "mot": [30, 7, 0], ...}
|
||||
alert_matrix = await config.get_setting(
|
||||
db,
|
||||
"NOTIFICATION_TYPE_MATRIX",
|
||||
scope_level="user",
|
||||
scope_id=str(user.id),
|
||||
default={
|
||||
"insurance": [45, 30, 15, 7, 1, 0],
|
||||
"mot": [30, 7, 1, 0],
|
||||
"personal_id": [30, 15, 0],
|
||||
"default": [30, 7, 1]
|
||||
}
|
||||
)
|
||||
|
||||
# 3. Ellenőrizendő dátumok (factory_data a Robotoktól)
|
||||
# Kulcsok: insurance_expiry_date, mot_expiry_date, id_card_expiry stb.
|
||||
check_map = {
|
||||
"insurance": asset.factory_data.get("insurance_expiry_date"),
|
||||
"mot": asset.factory_data.get("mot_expiry_date"),
|
||||
"personal_id": user.person.identity_docs.get("expiry_date") if user.person else None
|
||||
}
|
||||
|
||||
for doc_type, expiry_str in check_map.items():
|
||||
if not expiry_str: continue
|
||||
|
||||
try:
|
||||
expiry_dt = datetime.strptime(expiry_str, "%Y-%m-%d").date()
|
||||
days_until = (expiry_dt - today).days
|
||||
|
||||
# Megnézzük a típushoz tartozó admin-beállítást (pl. a 45 napot)
|
||||
alert_steps = alert_matrix.get(doc_type, alert_matrix["default"])
|
||||
|
||||
if days_until in alert_steps:
|
||||
# Prioritás meghatározása (Adminból is jöhetne, de itt kategória alapú)
|
||||
priority = "critical" if days_until <= 1 or (doc_type == "insurance" and days_until == 45) else "high"
|
||||
|
||||
title = f"Riasztás: {asset.license_plate} - {doc_type.upper()}"
|
||||
msg = f"A(z) {doc_type} dokumentum {days_until} nap múlva lejár ({expiry_str})."
|
||||
|
||||
if days_until == 45 and doc_type == "insurance":
|
||||
msg = f"🚨 BIZTOSÍTÁSI FORDULÓ! (45 nap). Most van időd felmondani a régit!"
|
||||
|
||||
await NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
title=title,
|
||||
message=msg,
|
||||
category=doc_type,
|
||||
priority=priority,
|
||||
data={"asset_id": str(asset.id), "vin": asset.vin, "days": days_until},
|
||||
email_template="expiry_alert",
|
||||
email_vars={
|
||||
"recipient": user.email,
|
||||
"first_name": user.person.first_name if user.person else "Partnerünk",
|
||||
"license_plate": asset.license_plate,
|
||||
"expiry_date": expiry_str,
|
||||
"days_left": days_until,
|
||||
"lang": user.preferred_language
|
||||
}
|
||||
)
|
||||
notifications_sent += 1
|
||||
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
return {"status": "success", "count": notifications_sent}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Notification System Error: {e}")
|
||||
raise e
|
||||
49
backend/app/services/recon_bot.py
Executable file
49
backend/app/services/recon_bot.py
Executable file
@@ -0,0 +1,49 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/recon_bot.py
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.asset import Asset, AssetCatalog, AssetTelemetry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def run_vehicle_recon(db: AsyncSession, asset_id: str):
|
||||
"""
|
||||
VIN alapján megkeresi a mélységi adatokat és frissíti a Digitális Ikert.
|
||||
"""
|
||||
stmt = select(Asset).where(Asset.id == asset_id)
|
||||
asset = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not asset or not asset.catalog_id:
|
||||
return False
|
||||
|
||||
logger.info(f"🤖 Robot indul: {asset.vin} felderítése...")
|
||||
|
||||
# --- LOGIKA MEGŐRIZVE: Szimulált mélységi adatgyűjtés ---
|
||||
await asyncio.sleep(2)
|
||||
|
||||
deep_data = {
|
||||
"assembly_plant": "Fremont, California",
|
||||
"drive_unit": "Dual Motor - Raven type",
|
||||
"onboard_charger": "11 kW",
|
||||
"supercharging_max": "250 kW",
|
||||
"safety_rating": "5-star EuroNCAP",
|
||||
"recon_timestamp": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
# 3. Katalógus frissítése (MDM elv)
|
||||
catalog = (await db.execute(select(AssetCatalog).where(AssetCatalog.id == asset.catalog_id))).scalar_one_or_none()
|
||||
if catalog:
|
||||
current_data = catalog.factory_data or {}
|
||||
current_data.update(deep_data)
|
||||
catalog.factory_data = current_data
|
||||
|
||||
# 4. Telemetria frissítése (VQI score csökkentése a logika szerint)
|
||||
telemetry = (await db.execute(select(AssetTelemetry).where(AssetTelemetry.asset_id == asset.id))).scalar_one_or_none()
|
||||
if telemetry:
|
||||
telemetry.vqi_score = 99.2
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✨ Robot végzett: {asset.license_plate or asset.vin} felokosítva.")
|
||||
return True
|
||||
40
backend/app/services/robot_manager.py
Executable file
40
backend/app/services/robot_manager.py
Executable file
@@ -0,0 +1,40 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/robot_manager.py
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from .harvester_cars import VehicleHarvester
|
||||
# Megjegyzés: Csak azokat importáld, amik öröklődnek a BaseHarvester-ből
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RobotManager:
|
||||
@staticmethod
|
||||
async def run_full_sync(db):
|
||||
""" Sorban lefuttatja a robotokat az új AssetCatalog struktúrához. """
|
||||
logger.info(f"🕒 Teljes szinkronizáció indítva: {datetime.now()}")
|
||||
|
||||
robots = [
|
||||
VehicleHarvester(),
|
||||
# BikeHarvester(), # Későbbi bővítéshez
|
||||
]
|
||||
|
||||
for robot in robots:
|
||||
try:
|
||||
# JAVÍTVA: A modern Harvesterek a harvest_all metódust használják
|
||||
await robot.harvest_all(db)
|
||||
logger.info(f"✅ {robot.category} robot sikeresen lefutott.")
|
||||
await asyncio.sleep(5)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Kritikus hiba a {robot.category} robotnál: {e}")
|
||||
|
||||
@staticmethod
|
||||
async def schedule_nightly_run(db):
|
||||
"""
|
||||
LOGIKA MEGŐRIZVE: Éjszakai futtatás 02:00-kor.
|
||||
"""
|
||||
while True:
|
||||
now = datetime.now()
|
||||
if now.hour == 2 and now.minute == 0:
|
||||
await RobotManager.run_full_sync(db)
|
||||
await asyncio.sleep(70) # Megakadályozzuk az újraindulást ugyanabban a percben
|
||||
await asyncio.sleep(30)
|
||||
141
backend/app/services/search_service.py
Executable file
141
backend/app/services/search_service.py
Executable file
@@ -0,0 +1,141 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/search_service.py
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from geoalchemy2.functions import ST_Distance, ST_MakePoint, ST_DWithin
|
||||
|
||||
from app.models.service import ServiceProfile, ExpertiseTag
|
||||
from app.models.organization import Organization
|
||||
from app.models.identity import User
|
||||
from app.services.config_service import config
|
||||
|
||||
logger = logging.getLogger("Search-Service-2.4-Agnostic")
|
||||
|
||||
class SearchService:
|
||||
"""
|
||||
Sentinel Master Search Service 2.4.
|
||||
Csomag-agnosztikus rangsoroló motor.
|
||||
Minden üzleti logika (súlyozás, prioritás, láthatóság) az adatbázisból jön.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def find_nearby_services(
|
||||
db: AsyncSession,
|
||||
lat: float,
|
||||
lon: float,
|
||||
current_user: User,
|
||||
expertise_key: str = None
|
||||
):
|
||||
try:
|
||||
# 1. HIERARCHIKUS RANGSOROLÁSI SZABÁLYOK LEKÉRÉSE
|
||||
# A config_service automatikusan a legspecifikusabbat adja vissza:
|
||||
# 1. User-specifikus (Céges egyedi beállítás)
|
||||
# 2. Package-specifikus (pl. 'free', 'premium', 'ultra_gold')
|
||||
# 3. Global (Alapértelmezett)
|
||||
|
||||
user_tier = current_user.tier_name
|
||||
|
||||
if current_user.role in [UserRole.superadmin, UserRole.admin]:
|
||||
user_tier = "vip"
|
||||
|
||||
ranking_rules = await config.get_setting(
|
||||
db,
|
||||
"RANKING_RULES",
|
||||
user_id=current_user.id, # Ez a belső config_service-ben kezeli a hierarchiát
|
||||
package_slug=user_tier,
|
||||
default={
|
||||
"ad_weight": 5000,
|
||||
"partner_weight": 1000,
|
||||
"trust_weight": 10,
|
||||
"dist_penalty": 20,
|
||||
"pref_weight": 0,
|
||||
"can_use_prefs": False,
|
||||
"search_radius_km": 30
|
||||
}
|
||||
)
|
||||
|
||||
# 2. PREFERENCIÁK (Ha a szabályrendszer engedi)
|
||||
user_prefs = {"networks": [], "ids": []}
|
||||
if ranking_rules.get("can_use_prefs", False):
|
||||
user_prefs = await config.get_setting(
|
||||
db, "USER_SEARCH_PREFERENCES",
|
||||
scope_level="user", scope_id=str(current_user.id),
|
||||
default={"networks": [], "ids": []}
|
||||
)
|
||||
|
||||
# 3. TÉRBELI LEKÉRDEZÉS
|
||||
user_point = ST_MakePoint(lon, lat)
|
||||
radius_m = ranking_rules.get("search_radius_km", 30) * 1000
|
||||
|
||||
distance_col = ST_Distance(ServiceProfile.location, user_point).label("distance_meters")
|
||||
|
||||
stmt = (
|
||||
select(ServiceProfile, Organization, distance_col)
|
||||
.join(Organization, ServiceProfile.organization_id == Organization.id)
|
||||
.where(
|
||||
and_(
|
||||
ST_DWithin(ServiceProfile.location, user_point, radius_m),
|
||||
ServiceProfile.is_active == True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if expertise_key:
|
||||
stmt = stmt.join(ServiceProfile.expertises).join(ExpertiseTag).where(ExpertiseTag.key == expertise_key)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 4. UNIVERZÁLIS PONTOZÁS (Súlyozott mátrix alapján)
|
||||
final_results = []
|
||||
r = ranking_rules # Rövidítés a számításhoz
|
||||
|
||||
for s_prof, org, dist_m in rows:
|
||||
dist_km = dist_m / 1000.0
|
||||
score = 0
|
||||
|
||||
# --- PONTOZÁSI LOGIKA (Nincsenek fix csomagnevek!) ---
|
||||
|
||||
# A. Hirdetési súly
|
||||
if s_prof.is_advertiser:
|
||||
score += r.get("ad_weight", 0)
|
||||
|
||||
# B. Partner (Minősített) súly
|
||||
if s_prof.is_verified_partner:
|
||||
score += r.get("partner_weight", 0)
|
||||
|
||||
# C. Minőség (Trust Score) súly
|
||||
score += (s_prof.trust_score * r.get("trust_weight", 0))
|
||||
|
||||
# D. Egyéni/Céges preferencia súly (Csak ha engedélyezett)
|
||||
if r.get("can_use_prefs"):
|
||||
if s_prof.network_slug in user_prefs.get("networks", []):
|
||||
score += r.get("pref_weight", 0)
|
||||
if str(org.id) in user_prefs.get("ids", []):
|
||||
score += r.get("pref_weight", 0) * 1.2
|
||||
|
||||
# E. Távolság büntetés
|
||||
score -= (dist_km * r.get("dist_penalty", 0))
|
||||
|
||||
final_results.append({
|
||||
"id": org.id,
|
||||
"name": org.full_name,
|
||||
"trust_score": s_prof.trust_score,
|
||||
"distance_km": round(dist_km, 2),
|
||||
"rank_score": round(score, 2),
|
||||
"flags": {
|
||||
"is_ad": s_prof.is_advertiser,
|
||||
"is_partner": s_prof.is_verified_partner,
|
||||
"is_favorite": str(org.id) in user_prefs.get("ids", [])
|
||||
}
|
||||
})
|
||||
|
||||
# 5. RENDEZÉS
|
||||
return sorted(final_results, key=lambda x: x['rank_score'], reverse=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Search Service 2.4 Critical Error: {e}")
|
||||
raise e
|
||||
94
backend/app/services/security_service.py
Executable file
94
backend/app/services/security_service.py
Executable file
@@ -0,0 +1,94 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/security_service.py
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Any, Dict
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.models.security import PendingAction, ActionStatus
|
||||
from app.models.history import AuditLog, LogSeverity
|
||||
from app.models.identity import User
|
||||
from app.models.system import SystemParameter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SecurityService:
|
||||
@staticmethod
|
||||
async def get_sec_config(db: AsyncSession) -> Dict[str, Any]:
|
||||
""" Lekéri a korlátokat a központi system_parameters-ből. """
|
||||
stmt = select(SystemParameter).where(SystemParameter.key.in_(["SECURITY_MAX_RECORDS_PER_HOUR", "SECURITY_DUAL_CONTROL_ENABLED"]))
|
||||
res = await db.execute(stmt)
|
||||
params = {p.key: p.value for p in res.scalars().all()}
|
||||
|
||||
return {
|
||||
"max_records": int(params.get("SECURITY_MAX_RECORDS_PER_HOUR", 500)),
|
||||
"dual_control": str(params.get("SECURITY_DUAL_CONTROL_ENABLED", "true")).lower() == "true"
|
||||
}
|
||||
|
||||
async def log_event(self, db: AsyncSession, user_id: Optional[int], action: str, severity: LogSeverity, **kwargs):
|
||||
""" LOGIKA MEGŐRIZVE: Audit naplózás + Emergency Lock trigger. """
|
||||
new_log = AuditLog(
|
||||
user_id=user_id, severity=severity, action=action,
|
||||
target_type=kwargs.get("target_type"), target_id=kwargs.get("target_id"),
|
||||
old_data=kwargs.get("old_data"), new_data=kwargs.get("new_data"),
|
||||
ip_address=kwargs.get("ip"), user_agent=kwargs.get("ua")
|
||||
)
|
||||
db.add(new_log)
|
||||
|
||||
if severity == LogSeverity.emergency:
|
||||
await self._execute_emergency_lock(db, user_id, f"Auto-lock by: {action}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
async def request_action(self, db: AsyncSession, requester_id: int, action_type: str, payload: Dict, reason: str):
|
||||
""" NÉGY SZEM ELV: Jóváhagyási kérelem indítása. """
|
||||
new_action = PendingAction(
|
||||
requester_id=requester_id, action_type=action_type,
|
||||
payload=payload, reason=reason, status=ActionStatus.pending
|
||||
)
|
||||
db.add(new_action)
|
||||
await db.commit()
|
||||
return new_action
|
||||
|
||||
async def approve_action(self, db: AsyncSession, approver_id: int, action_id: int):
|
||||
""" Jóváhagyás végrehajtása (Logic Preserved: Ön-jóváhagyás tiltva). """
|
||||
stmt = select(PendingAction).where(PendingAction.id == action_id)
|
||||
action = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not action or action.status != ActionStatus.pending:
|
||||
raise Exception("Művelet nem található.")
|
||||
if action.requester_id == approver_id:
|
||||
raise Exception("Saját kérést nem hagyhatsz jóvá!")
|
||||
|
||||
# Üzleti logika (pl. Role változtatás)
|
||||
if action.action_type == "CHANGE_ROLE":
|
||||
target_user = (await db.execute(select(User).where(User.id == action.payload.get("user_id")))).scalar_one_or_none()
|
||||
if target_user: target_user.role = action.payload.get("new_role")
|
||||
|
||||
action.status = ActionStatus.approved
|
||||
action.approver_id = approver_id
|
||||
action.processed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
async def check_data_access_limit(self, db: AsyncSession, user_id: int):
|
||||
""" DATA THROTTLING: Adatlopás elleni védelem. """
|
||||
config = await self.get_sec_config(db)
|
||||
limit_time = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
|
||||
stmt = select(func.count(AuditLog.id)).where(
|
||||
and_(AuditLog.user_id == user_id, AuditLog.timestamp >= limit_time, AuditLog.action.like("GET_%"))
|
||||
)
|
||||
count = (await db.execute(stmt)).scalar() or 0
|
||||
|
||||
if count > config["max_records"]:
|
||||
await self.log_event(db, user_id, "MASS_DATA_ACCESS", LogSeverity.emergency, reason=f"Count: {count}")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _execute_emergency_lock(self, db: AsyncSession, user_id: int, reason: str):
|
||||
if not user_id: return
|
||||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
||||
if user:
|
||||
user.is_active = False
|
||||
logger.critical(f"🚨 EMERGENCY LOCK: User {user_id} suspended. Reason: {reason}")
|
||||
|
||||
security_service = SecurityService()
|
||||
43
backend/app/services/social_auth_service.py
Executable file
43
backend/app/services/social_auth_service.py
Executable file
@@ -0,0 +1,43 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/social_auth_service.py
|
||||
import uuid
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.identity import User, Person, SocialAccount, UserRole
|
||||
from app.services.security_service import security_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SocialAuthService:
|
||||
@staticmethod
|
||||
async def get_or_create_social_user(db: AsyncSession, provider: str, social_id: str, email: str, first_name: str, last_name: str):
|
||||
"""
|
||||
LOGIKA MEGŐRIZVE: Step 1 regisztráció slug és flotta nélkül.
|
||||
"""
|
||||
# 1. Meglévő fiók ellenőrzése
|
||||
stmt = select(SocialAccount).where(SocialAccount.provider == provider, SocialAccount.social_id == social_id)
|
||||
social_acc = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if social_acc:
|
||||
return (await db.execute(select(User).where(User.id == social_acc.user_id))).scalar_one_or_none()
|
||||
|
||||
# 2. Új Identity és User (Step 1)
|
||||
stmt_u = select(User).where(User.email == email)
|
||||
user = (await db.execute(stmt_u)).scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
new_person = Person(first_name=first_name or "Social", last_name=last_name or "User", is_active=False)
|
||||
db.add(new_person)
|
||||
await db.flush()
|
||||
|
||||
user = User(email=email, person_id=new_person.id, role=UserRole.user, is_active=False)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
await security_service.log_event(db, user.id, "USER_REGISTER_SOCIAL", "info", target_type="User", target_id=str(user.id))
|
||||
|
||||
# 3. Kapcsolat rögzítése
|
||||
db.add(SocialAccount(user_id=user.id, provider=provider, social_id=social_id, email=email))
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
103
backend/app/services/social_service.py
Executable file
103
backend/app/services/social_service.py
Executable file
@@ -0,0 +1,103 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
|
||||
from app.models.social import ServiceProvider, Vote, ModerationStatus, Competition, UserScore
|
||||
from app.models.identity import User
|
||||
from app.schemas.social import ServiceProviderCreate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SocialService:
|
||||
"""
|
||||
SocialService: Kezeli a közösségi interakciókat, szavazatokat és a moderációt.
|
||||
Az importok a metódusokon belül vannak a körkörös függőség elkerülése érdekében.
|
||||
"""
|
||||
|
||||
async def create_service_provider(self, db: AsyncSession, obj_in: ServiceProviderCreate, user_id: int):
|
||||
from app.services.gamification_service import gamification_service
|
||||
|
||||
new_provider = ServiceProvider(**obj_in.model_dump(), added_by_user_id=user_id)
|
||||
db.add(new_provider)
|
||||
await db.flush()
|
||||
|
||||
# Alappontszám az új beküldésért
|
||||
await gamification_service.process_activity(db, user_id, 50, 10, f"New Provider: {new_provider.name}")
|
||||
await db.commit()
|
||||
await db.refresh(new_provider)
|
||||
return new_provider
|
||||
|
||||
async def vote_for_provider(self, db: AsyncSession, voter_id: int, provider_id: int, vote_value: int):
|
||||
from app.services.gamification_service import gamification_service
|
||||
|
||||
# Duplikált szavazat ellenőrzése
|
||||
exists = (await db.execute(select(Vote).where(and_(Vote.user_id == voter_id, Vote.provider_id == provider_id)))).scalar()
|
||||
if exists:
|
||||
return {"message": "Már szavaztál erre a szolgáltatóra!"}
|
||||
|
||||
db.add(Vote(user_id=voter_id, provider_id=provider_id, vote_value=vote_value))
|
||||
|
||||
provider = (await db.execute(select(ServiceProvider).where(ServiceProvider.id == provider_id))).scalar_one_or_none()
|
||||
if not provider:
|
||||
return {"error": "Szolgáltató nem található."}
|
||||
|
||||
provider.validation_score += vote_value
|
||||
|
||||
# Automatikus moderáció figyelése (csak a 'pending' állapotúaknál)
|
||||
if provider.status == ModerationStatus.pending:
|
||||
if provider.validation_score >= 5:
|
||||
provider.status = ModerationStatus.approved
|
||||
await self._reward_submitter(db, provider.added_by_user_id, provider.name)
|
||||
elif provider.validation_score <= -3:
|
||||
provider.status = ModerationStatus.rejected
|
||||
await self._penalize_user(db, provider.added_by_user_id, provider.name)
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "score": provider.validation_score, "new_status": provider.status}
|
||||
|
||||
async def get_leaderboard(self, db: AsyncSession, limit: int = 10):
|
||||
from app.services.gamification_service import gamification_service
|
||||
if hasattr(gamification_service, 'get_top_users'):
|
||||
return await gamification_service.get_top_users(db, limit)
|
||||
return []
|
||||
|
||||
async def _reward_submitter(self, db: AsyncSession, user_id: int, provider_name: str):
|
||||
""" Jutalmazás, ha a beküldött adatot jóváhagyta a közösség. """
|
||||
from app.services.gamification_service import gamification_service
|
||||
if not user_id: return
|
||||
|
||||
await gamification_service.process_activity(db, user_id, 100, 20, f"Validated: {provider_name}")
|
||||
|
||||
# Aktuális verseny keresése és pontozása
|
||||
now = datetime.now(timezone.utc)
|
||||
comp_stmt = select(Competition).where(and_(
|
||||
Competition.is_active == True,
|
||||
Competition.start_date <= now,
|
||||
Competition.end_date >= now
|
||||
))
|
||||
comp = (await db.execute(comp_stmt)).scalar_one_or_none()
|
||||
|
||||
if comp:
|
||||
us_stmt = select(UserScore).where(and_(UserScore.user_id == user_id, UserScore.competition_id == comp.id))
|
||||
us = (await db.execute(us_stmt)).scalar_one_or_none()
|
||||
if not us:
|
||||
us = UserScore(user_id=user_id, competition_id=comp.id, points=0)
|
||||
db.add(us)
|
||||
us.points += 10
|
||||
|
||||
async def _penalize_user(self, db: AsyncSession, user_id: int, provider_name: str):
|
||||
""" Büntetés, ha a beküldött adatot elutasította a közösség (is_penalty=True). """
|
||||
from app.services.gamification_service import gamification_service
|
||||
if not user_id: return
|
||||
|
||||
# JAVÍTVA: is_penalty=True hozzáadva a gamification híváshoz
|
||||
await gamification_service.process_activity(db, user_id, 50, 0, f"Rejected: {provider_name}", is_penalty=True)
|
||||
|
||||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
||||
if user and hasattr(user, 'reputation_score'):
|
||||
user.reputation_score = (user.reputation_score or 0) - 2
|
||||
if user.reputation_score <= -10:
|
||||
user.is_active = False
|
||||
|
||||
social_service = SocialService()
|
||||
31
backend/app/services/storage_service.py
Executable file
31
backend/app/services/storage_service.py
Executable file
@@ -0,0 +1,31 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/storage_service.py
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from minio import Minio
|
||||
from app.core.config import settings
|
||||
|
||||
class StorageService:
|
||||
# A klienst a beállításokból inicializáljuk
|
||||
client = Minio(
|
||||
settings.REDIS_URL.split("//")[1].split(":")[0], # Gyors fix a hostra vagy settings.MINIO_HOST
|
||||
access_key="minioadmin",
|
||||
secret_key="minioadmin",
|
||||
secure=False
|
||||
)
|
||||
BUCKET_NAME = "vehicle-documents"
|
||||
|
||||
@classmethod
|
||||
async def upload_document(cls, file_bytes: bytes, file_name: str, folder: str) -> str:
|
||||
""" Fájl feltöltése S3/Minio tárhelyre. """
|
||||
if not cls.client.bucket_exists(cls.BUCKET_NAME):
|
||||
cls.client.make_bucket(cls.BUCKET_NAME)
|
||||
|
||||
unique_name = f"{folder}/{uuid.uuid4()}_{file_name}"
|
||||
|
||||
cls.client.put_object(
|
||||
cls.BUCKET_NAME,
|
||||
unique_name,
|
||||
BytesIO(file_bytes),
|
||||
len(file_bytes)
|
||||
)
|
||||
return f"{cls.BUCKET_NAME}/{unique_name}"
|
||||
28
backend/app/services/translation.py
Executable file
28
backend/app/services/translation.py
Executable file
@@ -0,0 +1,28 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/translation.py
|
||||
from sqlalchemy import String, Text, Boolean, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.db.base_class import Base
|
||||
|
||||
class Translation(Base):
|
||||
"""
|
||||
Központi i18n adattábla.
|
||||
Minden rendszerüzenet és frontend felirat forrása.
|
||||
"""
|
||||
__tablename__ = "translations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("key", "lang_code", name="uq_translation_key_lang"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
|
||||
# A kulcs pontozott formátumú (pl: 'DASHBOARD.STATS.TITLE')
|
||||
key: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
|
||||
|
||||
# ISO kód (pl: 'hu', 'en', 'de')
|
||||
lang_code: Mapped[str] = mapped_column(String(5), nullable=False, index=True)
|
||||
|
||||
# A tényleges lefordított szöveg
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# Élesítési állapot (Draft/Published)
|
||||
is_published: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
114
backend/app/services/translation_service.py
Executable file
114
backend/app/services/translation_service.py
Executable file
@@ -0,0 +1,114 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/translation_service.py
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from app.models.translation import Translation
|
||||
from app.core.config import settings
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TranslationService:
|
||||
"""
|
||||
Dinamikus fordítás-kezelő szerviz.
|
||||
Támogatja a szerveroldali cache-elést és a frontend JSON exportot.
|
||||
"""
|
||||
# Memória-cache a szerveroldali hibaüzenetekhez és emailekhez
|
||||
_published_cache: Dict[str, Dict[str, str]] = {}
|
||||
|
||||
@classmethod
|
||||
async def load_cache(cls, db: AsyncSession):
|
||||
""" Betölti a publikált szövegeket a memóriába az adatbázisból. """
|
||||
stmt = select(Translation).where(Translation.is_published == True)
|
||||
result = await db.execute(stmt)
|
||||
translations = result.scalars().all()
|
||||
|
||||
cls._published_cache = {}
|
||||
for t in translations:
|
||||
# JAVÍTVA: t.lang_code helyett t.lang
|
||||
if t.lang not in cls._published_cache:
|
||||
cls._published_cache[t.lang] = {}
|
||||
cls._published_cache[t.lang][t.key] = t.value
|
||||
|
||||
logger.info(f"🌍 i18n Motor: {len(translations)} szöveg aktiválva a memóriában.")
|
||||
|
||||
@classmethod
|
||||
def get_text(cls, key: str, lang: str = "hu", variables: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""
|
||||
Szerveroldali lekérés Fallback (EN) logikával és változó behelyettesítéssel.
|
||||
Példa: get_text("AUTH.WELCOME", "hu", {"name": "Péter"})
|
||||
"""
|
||||
# 1. Kért nyelv lekérése
|
||||
text = cls._published_cache.get(lang, {}).get(key)
|
||||
|
||||
# 2. Fallback angolra, ha nincs meg a kért nyelven
|
||||
if not text and lang != "en":
|
||||
text = cls._published_cache.get("en", {}).get(key)
|
||||
|
||||
# 3. Ha sehol nincs meg, adjuk vissza a kulcsot
|
||||
if not text:
|
||||
return f"[{key}]"
|
||||
|
||||
# 4. Változók behelyettesítése (pl. {{name}})
|
||||
if variables:
|
||||
for k, v in variables.items():
|
||||
text = text.replace(f"{{{{{k}}}}}", str(v))
|
||||
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
async def publish_all(cls, db: AsyncSession):
|
||||
""" Minden piszkozatot élesít, frissíti a memóriát és legenerálja a JSON-öket. """
|
||||
await db.execute(
|
||||
update(Translation).where(Translation.is_published == False).values(is_published=True)
|
||||
)
|
||||
await db.commit()
|
||||
await cls.load_cache(db)
|
||||
await cls.export_to_json(db)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def export_to_json(db: AsyncSession):
|
||||
"""
|
||||
Adatbázis -> Hierarchikus JSON struktúra generálása a Frontend számára.
|
||||
'AUTH.LOGIN.TITLE' -> { "AUTH": { "LOGIN": { "TITLE": "..." } } }
|
||||
"""
|
||||
stmt = select(Translation).where(Translation.is_published == True)
|
||||
result = await db.execute(stmt)
|
||||
translations = result.scalars().all()
|
||||
|
||||
languages: Dict[str, Any] = {}
|
||||
for t in translations:
|
||||
# JAVÍTVA: t.lang_code helyett t.lang
|
||||
if t.lang not in languages:
|
||||
languages[t.lang] = {}
|
||||
|
||||
# Kulcs felbontása szintekre hierarchikus struktúrához
|
||||
parts = t.key.split('.')
|
||||
current_level = languages[t.lang]
|
||||
|
||||
for part in parts[:-1]:
|
||||
if part not in current_level:
|
||||
current_level[part] = {}
|
||||
current_level = current_level[part]
|
||||
|
||||
current_level[parts[-1]] = t.value
|
||||
|
||||
# Fájlok fizikai mentése a static könyvtárba
|
||||
locales_path = os.path.join(settings.STATIC_DIR, "locales")
|
||||
os.makedirs(locales_path, exist_ok=True)
|
||||
|
||||
for lang, content in languages.items():
|
||||
file_path = os.path.join(locales_path, f"{lang}.json")
|
||||
try:
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(content, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"✅ Nyelvi fájl (JSON) frissítve: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Hiba a fájl mentésekor ({lang}): {e}")
|
||||
|
||||
return True
|
||||
|
||||
translation_service = TranslationService()
|
||||
Reference in New Issue
Block a user