Initial commit: Robot ökoszisztéma v2.0 - Stabilizált jármű és szerviz robotok

This commit is contained in:
Kincses
2026-03-04 02:03:03 +01:00
commit 250f4f4b8f
7942 changed files with 449625 additions and 0 deletions

132
backend/app/api/auth.py.old Executable file
View File

@@ -0,0 +1,132 @@
from datetime import timedelta
from typing import Dict, Any
from fastapi import APIRouter, HTTPException
from app.core.config import settings
from app.core.security import create_token, decode_token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
def login(payload: Dict[str, Any]):
"""
payload:
{
"org_id": "<uuid>",
"login": "<username or email>",
"password": "<plain>"
}
"""
from app.db.session import get_conn
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("BEGIN;")
org_id = (payload.get("org_id") or "").strip()
login_id = (payload.get("login") or "").strip()
password = payload.get("password") or ""
if not org_id or not login_id or not password:
raise HTTPException(status_code=400, detail="org_id, login, password required")
# RLS miatt kötelező: org kontextus beállítás
cur.execute("SELECT set_config('app.tenant_org_id', %s, false);", (org_id,))
# account + credential
cur.execute(
"""
SELECT
a.account_id::text,
a.org_id::text,
a.username::text,
a.email::text,
c.password_hash,
c.is_active
FROM app.account a
JOIN app.account_credential c ON c.account_id = a.account_id
WHERE a.org_id = %s::uuid
AND (a.username = %s::citext OR a.email = %s::citext)
AND c.is_active = true
LIMIT 1;
""",
(org_id, login_id, login_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=401, detail="Invalid credentials")
account_id, org_id_db, username, email, password_hash, cred_active = row
# Jelszó ellenőrzés pgcrypto-val: crypt(plain, stored_hash) = stored_hash
cur.execute("SELECT crypt(%s, %s) = %s;", (password, password_hash, password_hash))
ok = cur.fetchone()[0]
if not ok:
raise HTTPException(status_code=401, detail="Invalid credentials")
# MVP: role később membershipből; most fixen tenant_admin
role_code = "tenant_admin"
is_platform_admin = False
access = create_token(
{
"sub": account_id,
"org_id": org_id_db,
"role": role_code,
"is_platform_admin": is_platform_admin,
"type": "access",
},
settings.JWT_SECRET,
timedelta(minutes=settings.JWT_ACCESS_MINUTES),
)
refresh = create_token(
{
"sub": account_id,
"org_id": org_id_db,
"role": role_code,
"is_platform_admin": is_platform_admin,
"type": "refresh",
},
settings.JWT_SECRET,
timedelta(days=settings.JWT_REFRESH_DAYS),
)
conn.commit()
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer"}
except HTTPException:
conn.rollback()
raise
except Exception as e:
conn.rollback()
raise HTTPException(status_code=500, detail=str(e))
finally:
conn.close()
@router.post("/refresh")
def refresh_token(payload: Dict[str, Any]):
token = payload.get("refresh_token") or ""
if not token:
raise HTTPException(status_code=400, detail="refresh_token required")
try:
claims = decode_token(token, settings.JWT_SECRET)
if claims.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token type")
access = create_token(
{
"sub": claims.get("sub"),
"org_id": claims.get("org_id"),
"role": claims.get("role"),
"is_platform_admin": claims.get("is_platform_admin", False),
"type": "access",
},
settings.JWT_SECRET,
timedelta(minutes=settings.JWT_ACCESS_MINUTES),
)
return {"access_token": access, "token_type": "bearer"}
except Exception:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")

139
backend/app/api/deps.py Executable file
View File

@@ -0,0 +1,139 @@
# /opt/docker/dev/service_finder/backend/app/api/deps.py
from typing import Optional, Dict, Any, Union
import logging
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.core.security import decode_token, DEFAULT_RANK_MAP
from app.models.identity import User, UserRole # JAVÍTVA: Új Identity modell használata
from app.core.config import settings
logger = logging.getLogger(__name__)
# --- GONDOLATMENET / THOUGHT PROCESS ---
# 1. Az OAuth2 folyamat a központosított bejelentkezési végponton keresztül fut.
# 2. A token visszafejtésekor ellenőrizni kell a 'type' mezőt, hogy ne lehessen refresh tokennel belépni.
# 3. A felhasználó lekérésekor a SQLAlchemy 2.0 aszinkron 'execute' és 'scalar_one_or_none' metódusait használjuk.
# 4. A Scoped RBAC (Role-Based Access Control) biztosítja, hogy a felhasználók ne férjenek hozzá egymás flottáihoz.
# ---------------------------------------
# Az OAuth2 folyamat a bejelentkezési végponton keresztül
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
async def get_current_token_payload(
token: str = Depends(reusable_oauth2)
) -> Dict[str, Any]:
"""
JWT token visszafejtése és a típus (access) ellenőrzése.
"""
# Fejlesztői bypass (opcionális, csak DEBUG módban)
if settings.DEBUG and token == "dev_bypass_active":
return {
"sub": "1",
"role": "superadmin",
"rank": 100,
"scope_level": "global",
"scope_id": "all",
"type": "access"
}
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen vagy lejárt munkamenet."
)
return payload
async def get_current_user(
db: AsyncSession = Depends(get_db),
payload: Dict = Depends(get_current_token_payload)
) -> User:
"""
Lekéri a felhasználót a token 'sub' mezője alapján (SQLAlchemy 2.0 aszinkron módon).
"""
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token azonosítási hiba."
)
# JAVÍTVA: Modern SQLAlchemy 2.0 aszinkron lekérdezés
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user or user.is_deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A felhasználó nem található."
)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Ellenőrzi, hogy a felhasználó aktív-e (KYC Step 2 kész).
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="A művelethez aktív profil és KYC azonosítás szükséges."
)
return current_user
async def check_resource_access(
resource_scope_id: Union[str, int],
current_user: User = Depends(get_current_user)
):
"""
Scoped RBAC: Megakadályozza a jogosulatlan hozzáférést mások adataihoz.
"""
if current_user.role == UserRole.superadmin:
return True
user_scope = str(current_user.scope_id) if current_user.scope_id else None
requested_scope = str(resource_scope_id)
# 1. Saját ID ellenőrzése
if str(current_user.id) == requested_scope:
return True
# 2. Szervezeti/Flotta scope ellenőrzése
if user_scope and user_scope == requested_scope:
return True
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nincs jogosultsága ehhez az erőforráshoz."
)
def check_min_rank(role_key: str):
"""
Dinamikus Rank ellenőrzés a system_parameters tábla alapján.
"""
async def rank_checker(
db: AsyncSession = Depends(get_db),
payload: Dict = Depends(get_current_token_payload)
):
# A settings.get_db_setting-et használjuk a dinamikus lekéréshez
ranks = await settings.get_db_setting(
db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP
)
required_rank = ranks.get(role_key, 0)
user_rank = payload.get("rank", 0)
if user_rank < required_rank:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Alacsony jogosultsági szint. (Elvárt: {required_rank})"
)
return True
return rank_checker

17
backend/app/api/recommend.py Executable file
View File

@@ -0,0 +1,17 @@
# /opt/docker/dev/service_finder/backend/app/api/recommend.py
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from app.db.session import get_db
router = APIRouter()
@router.get("/provider/inbox")
async def provider_inbox(provider_id: str, db: AsyncSession = Depends(get_db)):
""" Aszinkron szerviz-postaláda lekérdezés. """
query = text("""
SELECT * FROM data.service_profiles
WHERE id = :p_id
""")
result = await db.execute(query, {"p_id": provider_id})
return [dict(row._mapping) for row in result.fetchall()]

20
backend/app/api/v1/api.py Executable file
View File

@@ -0,0 +1,20 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/api.py
from fastapi import APIRouter
from app.api.v1.endpoints import (
auth, catalog, assets, organizations, documents,
services, admin, expenses, evidence, social
)
api_router = APIRouter()
# Minden modul az új, refaktorált végpontokra mutat
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(services.router, prefix="/services", tags=["Service Hunt & Discovery"])
api_router.include_router(catalog.router, prefix="/catalog", tags=["Vehicle Catalog"])
api_router.include_router(assets.router, prefix="/assets", tags=["Assets"])
api_router.include_router(organizations.router, prefix="/organizations", tags=["Organizations"])
api_router.include_router(documents.router, prefix="/documents", tags=["Documents"])
api_router.include_router(admin.router, prefix="/admin", tags=["Admin Control Center (Sentinel)"])
api_router.include_router(evidence.router, prefix="/evidence", tags=["Evidence & OCR (Robot 3)"])
api_router.include_router(expenses.router, prefix="/expenses", tags=["Fleet Expenses (TCO)"])
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])

View File

@@ -0,0 +1,131 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, delete
from typing import List, Any, Dict, Optional
from datetime import datetime, timedelta
from app.api import deps
from app.models.identity import User, UserRole # JAVÍTVA: Központi import
from app.models.system import SystemParameter
# JAVÍTVA: Security audit modellek
from app.models.audit import SecurityAuditLog, OperationalLog
# JAVÍTVA: Ezek a modellek a security.py-ból jönnek (ha ott vannak)
from app.models.security import PendingAction, ActionStatus
from app.services.security_service import security_service
from app.services.translation_service import TranslationService
from pydantic import BaseModel
class ConfigUpdate(BaseModel):
key: str
value: Any
scope_level: str = "global"
scope_id: Optional[str] = None
category: str = "general"
router = APIRouter()
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
""" Csak Admin vagy Superadmin. """
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sentinel jogosultság szükséges!"
)
return current_user
@router.get("/health-monitor", tags=["Sentinel Monitoring"])
async def get_system_health(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
stats = {}
# Adatbázis statisztikák (Nyers SQL marad, mert hatékony)
user_stats = await db.execute(text("SELECT subscription_plan, count(*) FROM data.users GROUP BY subscription_plan"))
stats["user_distribution"] = {row[0]: row[1] for row in user_stats}
asset_count = await db.execute(text("SELECT count(*) FROM data.assets"))
stats["total_assets"] = asset_count.scalar()
org_count = await db.execute(text("SELECT count(*) FROM data.organizations"))
stats["total_organizations"] = org_count.scalar()
# JAVÍTVA: Biztonsági státusz az új SecurityAuditLog alapján
day_ago = datetime.now() - timedelta(days=1)
crit_logs = await db.execute(
select(func.count(SecurityAuditLog.id))
.where(
SecurityAuditLog.is_critical == True,
SecurityAuditLog.created_at >= day_ago
)
)
stats["critical_alerts_24h"] = crit_logs.scalar() or 0
return stats
@router.get("/pending-actions", response_model=List[Any], tags=["Sentinel Security"])
async def list_pending_actions(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
stmt = select(PendingAction).where(PendingAction.status == ActionStatus.pending)
result = await db.execute(stmt)
return result.scalars().all()
@router.post("/approve/{action_id}", tags=["Sentinel Security"])
async def approve_action(
action_id: int,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
try:
await security_service.approve_action(db, admin.id, action_id)
return {"status": "success", "message": "Művelet végrehajtva."}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/parameters", tags=["Dynamic Configuration"])
async def list_all_parameters(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
result = await db.execute(select(SystemParameter))
return result.scalars().all()
@router.post("/parameters", tags=["Dynamic Configuration"])
async def set_parameter(
config: ConfigUpdate,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
query = text("""
INSERT INTO data.system_parameters (key, value, scope_level, scope_id, category, last_modified_by)
VALUES (:key, :val, :sl, :sid, :cat, :user)
ON CONFLICT (key, scope_level, scope_id)
DO UPDATE SET
value = EXCLUDED.value,
category = EXCLUDED.category,
last_modified_by = EXCLUDED.last_modified_by,
updated_at = now()
""")
await db.execute(query, {
"key": config.key,
"val": config.value,
"sl": config.scope_level,
"sid": config.scope_id,
"cat": config.category,
"user": admin.email
})
await db.commit()
return {"status": "success", "message": f"'{config.key}' frissítve."}
@router.post("/translations/sync", tags=["System Utilities"])
async def sync_translations_to_json(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
await TranslationService.export_to_json(db)
return {"message": "JSON fájlok frissítve."}

View File

@@ -0,0 +1,54 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py
import uuid
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy.orm import selectinload
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models.asset import Asset, AssetCost
from app.models.identity import User
from app.services.cost_service import cost_service
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
from app.schemas.asset import AssetResponse
router = APIRouter()
@router.get("/{asset_id}/financial-summary", response_model=Dict[str, Any])
async def get_asset_financial_report(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
MB 2.0 Dinamikus Pénzügyi Riport.
Visszaadja a kategóriákra bontott és az összesített költségeket (Local/EUR).
"""
# 1. Jogosultság ellenőrzése (Csak a tulajdonos vagy admin láthatja)
# (Itt egy gyors check, hogy az asset az övé-e)
try:
return await cost_service.get_asset_financial_summary(db, asset_id)
except Exception as e:
raise HTTPException(status_code=500, detail="Hiba a riport generálásakor")
@router.get("/{asset_id}/costs", response_model=List[AssetCostResponse])
async def list_asset_costs(
asset_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Tételes költséglista lapozással (Pagination)."""
stmt = (
select(AssetCost)
.where(AssetCost.asset_id == asset_id)
.order_by(desc(AssetCost.date))
.offset(skip)
.limit(limit)
)
res = await db.execute(stmt)
return res.scalars().all()

View File

@@ -0,0 +1,41 @@
# backend/app/api/v1/endpoints/auth.py
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.services.auth_service import AuthService
from app.core.security import create_tokens, DEFAULT_RANK_MAP
from app.core.config import settings
from app.schemas.auth import UserLiteRegister, Token, UserKYCComplete
from app.api.deps import get_current_user
from app.models.identity import User # JAVÍTVA: Új központi modell
router = APIRouter()
@router.post("/login", response_model=Token)
async def login(db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()):
user = await AuthService.authenticate(db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=401, detail="Hibás adatok.")
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_name, 10),
"scope_level": user.scope_level or "individual",
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
}
access, refresh = create_tokens(data=token_data)
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer", "is_active": user.is_active}
@router.post("/complete-kyc")
async def complete_kyc(kyc_in: UserKYCComplete, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
user = await AuthService.complete_kyc(db, current_user.id, kyc_in)
if not user:
raise HTTPException(status_code=404, detail="User nem található.")
return {"status": "success", "message": "Fiók aktiválva."}

View File

@@ -0,0 +1,63 @@
# backend/app/api/v1/endpoints/billing.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.api.deps import get_db, get_current_user
from app.models.identity import User, Wallet, UserRole
from app.models.audit import FinancialLedger
from app.services.config_service import config
router = APIRouter()
@router.post("/upgrade")
async def upgrade_account(target_package: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""
Univerzális csomagváltó.
Kezeli az 5+ csomagot, a Rank-ugrást és a különleges 'Service Coin' bónuszokat.
"""
# 1. Lekérjük a teljes csomagmátrixot az adminból
# Példa JSON: {"premium": {"price": 2000, "rank": 5, "type": "credit"}, "service_pro": {"price": 10000, "rank": 30, "type": "coin"}}
package_matrix = await config.get_setting(db, "subscription_packages_matrix")
if target_package not in package_matrix:
raise HTTPException(status_code=400, detail="Érvénytelen csomagválasztás.")
pkg_info = package_matrix[target_package]
price = pkg_info["price"]
# 2. Pénztárca ellenőrzése
stmt = select(Wallet).where(Wallet.user_id == current_user.id)
wallet = (await db.execute(stmt)).scalar_one_or_none()
total_balance = wallet.purchased_credits + wallet.earned_credits
if total_balance < price:
raise HTTPException(status_code=402, detail="Nincs elég kredited a csomagváltáshoz.")
# 3. Levonási logika (Purchased -> Earned sorrend)
if wallet.purchased_credits >= price:
wallet.purchased_credits -= price
else:
remaining = price - wallet.purchased_credits
wallet.purchased_credits = 0
wallet.earned_credits -= remaining
# 4. Speciális Szerviz Logika (Service Coins)
# Ha a csomag típusa 'coin', akkor a szerviz kap egy kezdő Coin csomagot is
if pkg_info.get("type") == "coin":
initial_coins = pkg_info.get("initial_coin_bonus", 100)
wallet.service_coins += initial_coins
logger.info(f"User {current_user.id} upgraded to Service Pro, awarded {initial_coins} coins.")
# 5. Rang frissítése és naplózás
current_user.role = target_package # Pl. 'service_pro' vagy 'vip'
db.add(FinancialLedger(
user_id=current_user.id,
amount=-price,
transaction_type=f"UPGRADE_{target_package.upper()}",
details=pkg_info
))
await db.commit()
return {"status": "success", "package": target_package, "rank_granted": pkg_info["rank"]}

View File

@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.services.asset_service import AssetService
from typing import List
router = APIRouter()
@router.get("/makes", response_model=List[str])
async def list_makes(db: AsyncSession = Depends(get_db)):
"""1. Szint: Márkák listázása."""
return await AssetService.get_makes(db)
@router.get("/models", response_model=List[str])
async def list_models(make: str, db: AsyncSession = Depends(get_db)):
"""2. Szint: Típusok listázása egy adott márkához."""
models = await AssetService.get_models(db, make)
if not models:
raise HTTPException(status_code=404, detail="Márka nem található vagy nincsenek típusok.")
return models
@router.get("/generations", response_model=List[str])
async def list_generations(make: str, model: str, db: AsyncSession = Depends(get_db)):
"""3. Szint: Generációk/Évjáratok listázása."""
generations = await AssetService.get_generations(db, make, model)
if not generations:
raise HTTPException(status_code=404, detail="Nincs generációs adat ehhez a típushoz.")
return generations
@router.get("/engines")
async def list_engines(make: str, model: str, gen: str, db: AsyncSession = Depends(get_db)):
"""4. Szint: Motorváltozatok és technikai specifikációk."""
engines = await AssetService.get_engines(db, make, model, gen)
if not engines:
raise HTTPException(status_code=404, detail="Nincs motorváltozat adat.")
# Itt visszaküldjük a teljes katalógus objektumokat (ID, motorváltozat, specifikációk)
return [
{
"id": e.id,
"variant": e.engine_variant,
"engine_code": e.engine_code,
"fuel_type": e.fuel_type,
"factory_data": e.factory_data
} for e in engines
]

View File

@@ -0,0 +1,87 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/documents.py
import uuid
from typing import Any, Dict
from fastapi import APIRouter, Depends, UploadFile, File, Form, BackgroundTasks, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.api.deps import get_current_user
from app.services.document_service import DocumentService
from app.models.identity import User
router = APIRouter()
@router.post("/upload/{parent_type}/{parent_id}")
async def upload_document(
parent_type: str,
parent_id: str,
background_tasks: BackgroundTasks,
doc_type: str = Form(..., description="A dokumentum típusa: 'invoice', 'registration_card', 'sale_contract'"),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
MB 2.0 Dokumentum Pipeline.
1. Ellenőrzi a felhasználó havi OCR kvótáját (Admin 2.0).
2. Optimalizálja és NAS-ra menti a képet (WebP konverzió).
3. Automatikusan elindítja a Robot 1-et (OCR), ha a típus engedélyezett.
"""
# 1. Bemeneti validáció
valid_parents = ["organizations", "assets", "transfers"]
if parent_type not in valid_parents:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Érvénytelen cél-típus! Megengedett: {', '.join(valid_parents)}"
)
try:
# 2. Feldolgozás a szolgáltatás rétegben
# Itt történik a kvóta-ellenőrzés és a Robot 1 triggerelése is
doc = await DocumentService.process_upload(
db=db,
user_id=current_user.id,
file=file,
parent_type=parent_type,
parent_id=parent_id,
doc_type=doc_type,
background_tasks=background_tasks
)
# 3. Válasz összeállítása az állapot alapján
response_data = {
"document_id": doc.id,
"original_name": doc.original_name,
"status": doc.status,
"thumbnail": doc.thumbnail_path,
}
if doc.status == "processing":
response_data["message"] = "🤖 Robot 1 megkezdte a dokumentum elemzését. Értesítjük, ha kész!"
else:
response_data["message"] = "Dokumentum sikeresen archiválva a széfben."
return response_data
except HTTPException as he:
# Közvetlenül átengedjük a service-ből jövő (pl. kvóta) hibákat
raise he
except ValueError as ve:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(ve))
except Exception as e:
# Sentinel naplózás és általános hiba
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Kritikus hiba a dokumentum feldolgozása során."
)
@router.get("/{document_id}/status")
async def get_document_status(
document_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Lekérdezhető, hogy a robot végzett-e már a feldolgozással."""
# (Itt egy egyszerű lekérdezés a Document táblából a státuszra)
pass

View File

@@ -0,0 +1,24 @@
# backend/app/api/v1/endpoints/evidence.py
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text
from app.api.deps import get_db, get_current_user
from app.models.identity import User
from app.models.asset import Asset # JAVÍTVA: Asset modell
router = APIRouter()
@router.post("/scan-registration")
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
stmt_limit = text("SELECT (value->>:plan)::int FROM data.system_parameters WHERE key = 'VEHICLE_LIMIT'")
res = await db.execute(stmt_limit, {"plan": current_user.subscription_plan or "free"})
max_allowed = res.scalar() or 1
stmt_count = select(func.count(Asset.id)).where(Asset.owner_organization_id == current_user.scope_id)
count = (await db.execute(stmt_count)).scalar() or 0
if count >= max_allowed:
raise HTTPException(status_code=403, detail=f"Limit túllépés: {max_allowed} jármű engedélyezett.")
# OCR hívás helye...
return {"success": True, "message": "Feldolgozás megkezdődött."}

View File

@@ -0,0 +1,33 @@
# backend/app/api/v1/endpoints/expenses.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.api.deps import get_db, get_current_user
from app.models.asset import Asset, AssetCost # JAVÍTVA
from pydantic import BaseModel
from datetime import date
router = APIRouter()
class ExpenseCreate(BaseModel):
asset_id: str
category: str
amount: float
date: date
@router.post("/add")
async def add_expense(expense: ExpenseCreate, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
stmt = select(Asset).where(Asset.id == expense.asset_id)
if not (await db.execute(stmt)).scalar_one_or_none():
raise HTTPException(status_code=404, detail="Jármű nem található.")
new_cost = AssetCost(
asset_id=expense.asset_id,
cost_type=expense.category,
amount_local=expense.amount,
date=expense.date,
currency_local="HUF"
)
db.add(new_cost)
await db.commit()
return {"status": "success"}

View File

@@ -0,0 +1,40 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/gamification.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import List
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models.identity import User
from app.models.gamification import UserStats, PointsLedger
from app.services.config_service import config
router = APIRouter()
@router.get("/my-stats")
async def get_my_stats(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""A bejelentkezett felhasználó aktuális XP-je, szintje és büntetőpontjai."""
stmt = select(UserStats).where(UserStats.user_id == current_user.id)
stats = (await db.execute(stmt)).scalar_one_or_none()
if not stats:
return {"total_xp": 0, "current_level": 1, "penalty_points": 0}
return stats
@router.get("/leaderboard")
async def get_leaderboard(limit: int = 10, db: AsyncSession = Depends(get_db)):
"""A 10 legtöbb XP-vel rendelkező felhasználó listája."""
stmt = (
select(User.email, UserStats.total_xp, UserStats.current_level)
.join(UserStats, User.id == UserStats.user_id)
.order_by(desc(UserStats.total_xp))
.limit(limit)
)
result = await db.execute(stmt)
# Az email-eket maszkoljuk a GDPR miatt (pl. k***s@p***.hu)
return [
{"user": f"{r[0][:2]}***@{r[0].split('@')[1]}", "xp": r[1], "level": r[2]}
for r in result.all()
]

View File

@@ -0,0 +1,100 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/notifications.py
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, desc, func
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models.identity import User
from app.models.system import InternalNotification
from app.schemas.social import NotificationResponse, NotificationUpdate # Feltételezett sémák
router = APIRouter()
@router.get("/my", response_model=List[NotificationResponse])
async def get_my_notifications(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
unread_only: bool = False,
limit: int = Query(50, ge=1, le=100),
offset: int = 0
):
"""
Lekéri a bejelentkezett felhasználó értesítéseit.
Támogatja a szűrést az olvasatlanokra és a lapozást.
"""
stmt = (
select(InternalNotification)
.where(InternalNotification.user_id == current_user.id)
)
if unread_only:
stmt = stmt.where(InternalNotification.is_read == False)
stmt = stmt.order_by(desc(InternalNotification.created_at)).offset(offset).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
@router.post("/mark-read")
async def mark_as_read(
notification_ids: List[uuid.UUID],
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Egy vagy több értesítés olvasottnak jelölése.
"""
if not notification_ids:
raise HTTPException(status_code=400, detail="Nincs megadva azonosító.")
stmt = (
update(InternalNotification)
.where(InternalNotification.id.in_(notification_ids))
.where(InternalNotification.user_id == current_user.id)
.values(is_read=True, read_at=func.now())
)
await db.execute(stmt)
await db.commit()
return {"status": "success", "marked_count": len(notification_ids)}
@router.post("/mark-all-read")
async def mark_all_read(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
A felhasználó összes értesítésének olvasottnak jelölése.
"""
stmt = (
update(InternalNotification)
.where(InternalNotification.user_id == current_user.id)
.where(InternalNotification.is_read == False)
.values(is_read=True, read_at=func.now())
)
await db.execute(stmt)
await db.commit()
return {"status": "success", "message": "Minden értesítés olvasottnak jelölve."}
@router.get("/summary")
async def get_notification_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Gyors összesítő a Dashboard-hoz (pl. hány olvasatlan van).
"""
stmt = (
select(func.count(InternalNotification.id))
.where(InternalNotification.user_id == current_user.id)
.where(InternalNotification.is_read == False)
)
unread_count = (await db.execute(stmt)).scalar() or 0
return {"unread_count": unread_count}

View File

@@ -0,0 +1,115 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/organizations.py
import os
import re
import uuid
import hashlib
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db.session import get_db
from app.api.deps import get_current_user
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse
from app.models.organization import Organization, OrgType, OrganizationMember
from app.models.identity import User # JAVÍTVA: Központi Identity modell
from app.core.config import settings
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/onboard", response_model=CorpOnboardResponse, status_code=status.HTTP_201_CREATED)
async def onboard_organization(
org_in: CorpOnboardIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Új szervezet (cég/szerviz) rögzítése.
Automatikusan generál slug-ot és létrehozza a NAS mappa-struktúrát.
"""
# 1. Magyar adószám validáció (XXXXXXXX-Y-ZZ)
if org_in.country_code == "HU":
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Érvénytelen magyar adószám formátum!"
)
# 2. Duplikáció ellenőrzés
stmt_exist = select(Organization).where(Organization.tax_number == org_in.tax_number)
result_exist = await db.execute(stmt_exist)
if result_exist.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Ezzel az adószámmal már regisztráltak céget!"
)
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
# Mivel az adatbázisban NOT NULL, itt muszáj létrehozni
temp_slug = hashlib.md5(f"{org_in.tax_number}-{uuid.uuid4()}".encode()).hexdigest()[:12]
# 4. Mentés
new_org = Organization(
full_name=org_in.full_name,
name=org_in.name,
display_name=org_in.display_name,
tax_number=org_in.tax_number,
reg_number=org_in.reg_number,
folder_slug=temp_slug, # JAVÍTVA: Kötelező mező beillesztve
address_zip=org_in.address_zip,
address_city=org_in.address_city,
address_street_name=org_in.address_street_name,
address_street_type=org_in.address_street_type,
address_house_number=org_in.address_house_number,
address_hrsz=org_in.address_hrsz,
address_stairwell=org_in.address_stairwell,
address_floor=org_in.address_floor,
address_door=org_in.address_door,
country_code=org_in.country_code,
org_type=OrgType.business,
status="pending_verification"
)
db.add(new_org)
await db.flush()
# 5. TULAJDONOS RÖGZÍTÉSE
owner_member = OrganizationMember(
organization_id=new_org.id,
user_id=current_user.id,
role="OWNER" # JAVÍTVA: Enum kompatibilis nagybetűs forma
)
db.add(owner_member)
# 6. NAS Mappa létrehozása
try:
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
org_path = os.path.join(base_path, "organizations", str(new_org.id))
os.makedirs(os.path.join(org_path, "documents"), exist_ok=True)
logger.info(f"NAS mappa kész: {org_path}")
except Exception as e:
logger.error(f"NAS hiba: {e}")
await db.commit()
await db.refresh(new_org)
return {"organization_id": new_org.id, "status": new_org.status}
@router.get("/my", response_model=List[CorpOnboardResponse])
async def get_my_organizations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
""" A bejelentkezett felhasználóhoz tartozó összes szervezet listázása. """
stmt = (
select(Organization)
.join(OrganizationMember)
.where(OrganizationMember.user_id == current_user.id)
)
result = await db.execute(stmt)
orgs = result.scalars().all()
return [{"organization_id": o.id, "status": o.status} for o in orgs]

View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.schemas.social import ServiceProviderCreate, ServiceProviderResponse
from app.services.social_service import create_service_provider
router = APIRouter()
@router.post("/", response_model=ServiceProviderResponse)
async def add_provider(provider_data: ServiceProviderCreate, db: AsyncSession = Depends(get_db)):
user_id = 2
return await create_service_provider(db, provider_data, user_id)

View File

@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from app.api.deps import get_db, get_current_user
router = APIRouter() # EZ HIÁNYZOTT!
@router.get("/summary/{vehicle_id}")
async def get_vehicle_summary(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
"""
Összesített jelentés egy járműhöz: kategóriánkénti költségek.
"""
query = text("""
SELECT
category,
SUM(amount) as total_amount,
COUNT(*) as transaction_count
FROM data.vehicle_expenses
WHERE vehicle_id = :v_id
GROUP BY category
""")
result = await db.execute(query, {"v_id": vehicle_id})
rows = result.fetchall()
total_cost = sum(row.total_amount for row in rows) if rows else 0
return {
"vehicle_id": vehicle_id,
"total_cost": float(total_cost),
"breakdown": [dict(row._mapping) for row in rows]
}
@router.get("/trends/{vehicle_id}")
async def get_monthly_trends(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
"""
Visszaadja az utolsó 6 hónap költéseit havi bontásban.
"""
query = text("""
SELECT
TO_CHAR(date, 'YYYY-MM') as month,
SUM(amount) as monthly_total
FROM data.vehicle_expenses
WHERE vehicle_id = :v_id
GROUP BY month
ORDER BY month DESC
LIMIT 6
""")
result = await db.execute(query, {"v_id": vehicle_id})
return [dict(row._mapping) for row in result.fetchall()]

View File

@@ -0,0 +1,24 @@
# backend/app/api/v1/endpoints/search.py
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models.organization import Organization # JAVÍTVA
router = APIRouter()
@router.get("/match")
async def match_service(lat: float, lng: float, radius: int = 20, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
# PostGIS alapú keresés a data.branches táblában (a régi locations helyett)
query = text("""
SELECT o.id, o.name, b.city,
ST_Distance(b.location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography) / 1000 as distance
FROM data.organizations o
JOIN data.branches b ON o.id = b.organization_id
WHERE o.is_active = True AND b.is_active = True
AND ST_DWithin(b.location, ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)::geography, :r * 1000)
ORDER BY distance ASC
""")
result = await db.execute(query, {"lat": lat, "lng": lng, "r": radius})
return {"results": [dict(row._mapping) for row in result.fetchall()]}

View File

@@ -0,0 +1,58 @@
from fastapi import APIRouter, Depends, Form, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, text
from typing import List, Optional
from app.db.session import get_db
from app.services.gamification_service import GamificationService
from app.models.service import ServiceProfile, ExpertiseTag, ServiceExpertise
router = APIRouter()
# --- 🎯 SZERVIZ VADÁSZAT (Service Hunt) ---
@router.post("/hunt")
async def register_service_hunt(
name: str = Form(...),
lat: float = Form(...),
lng: float = Form(...),
db: AsyncSession = Depends(get_db)
):
""" Új szerviz-jelölt rögzítése a staging táblába jutalompontért. """
# Új szerviz-jelölt rögzítése
await db.execute(text("""
INSERT INTO data.service_staging (name, fingerprint, status, raw_data)
VALUES (:n, :f, 'pending', jsonb_build_object('lat', :lat, 'lng', :lng))
"""), {"n": name, "f": f"{name}-{lat}-{lng}", "lat": lat, "lng": lng})
# MB 2.0 Gamification: 50 pont a felfedezésért
# TODO: A 1-es ID helyett a bejelentkezett felhasználót kell használni (current_user.id)
await GamificationService.award_points(db, 1, 50, f"Service Hunt: {name}")
await db.commit()
return {"status": "success", "message": "Discovery registered and points awarded."}
# --- 🔍 SZERVIZ KERESŐ (Service Search) ---
@router.get("/search")
async def search_services(
expertise_key: Optional[str] = Query(None, description="Szakmai címke (pl. brake_service)"),
city: Optional[str] = Query(None, description="Város szűrés"),
min_confidence: int = Query(0, description="Minimum hitelességi szint (0-2)"),
db: AsyncSession = Depends(get_db)
):
""" Szakmai szempontú keresőmotor, ami a validált címkék alapján szűr. """
# Alap lekérdezés: Szervizek, akiknek van szakértelmük
query = select(ServiceProfile).join(ServiceProfile.expertises).join(ServiceExpertise.tag)
filters = []
if expertise_key:
filters.append(ExpertiseTag.key == expertise_key)
if city:
filters.append(ServiceProfile.city.ilike(f"%{city}%"))
if min_confidence > 0:
filters.append(ServiceExpertise.confidence_level >= min_confidence)
if filters:
query = query.where(and_(*filters))
result = await db.execute(query.distinct())
services = result.scalars().all()
return services

View File

@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
# ITT A JAVÍTÁS: A példányt importáljuk, nem a régi függvényeket
from app.services.social_service import social_service
router = APIRouter()
@router.get("/leaderboard")
async def read_leaderboard(limit: int = 10, db: AsyncSession = Depends(get_db)):
return await social_service.get_leaderboard(db, limit)
@router.post("/vote/{provider_id}")
async def provider_vote(provider_id: int, vote_value: int, db: AsyncSession = Depends(get_db)):
user_id = 2
return await social_service.vote_for_provider(db, user_id, provider_id, vote_value)

View File

@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_current_user
from app.schemas.user import UserResponse
from app.models.user import User
router = APIRouter()
@router.get("/me", response_model=UserResponse)
async def read_users_me(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Visszaadja a bejelentkezett felhasználó profilját"""
return current_user

0
backend/app/core/__init__.py Executable file
View File

104
backend/app/core/config.py Executable file
View File

@@ -0,0 +1,104 @@
# /opt/docker/dev/service_finder/backend/app/core/config.py
import os
from pathlib import Path
from typing import Any, Optional, List
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, field_validator
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone
class Settings(BaseSettings):
# --- Paths ---
BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
STATIC_DIR: str = os.path.join(str(BASE_DIR), "static")
# --- General ---
PROJECT_NAME: str = "Service Finder Ecosystem"
VERSION: str = "2.1.0"
API_V1_STR: str = "/api/v1"
DEBUG: bool = False
def get_now_utc_iso(self) -> str:
"""Központi időlekérdező az egész Sentinel rendszernek"""
return datetime.now(timezone.utc).isoformat()
# MB 2.0 Kompatibilitási alias a database.py számára
@property
def DEBUG_MODE(self) -> bool:
return self.DEBUG
# --- Security / JWT ---
SECRET_KEY: str = "NOT_SET_DANGER"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# --- Initial Admin ---
INITIAL_ADMIN_EMAIL: str = "admin@servicefinder.hu"
INITIAL_ADMIN_PASSWORD: str = "Admin123!"
# --- Database & Cache ---
# Alapértelmezett értéket adunk, hogy ne szálljon el, ha a .env hiányos
DATABASE_URL: str = Field(
default="postgresql+asyncpg://user:password@postgres-db:5432/service_finder",
env="DATABASE_URL"
)
REDIS_URL: str = "redis://service_finder_redis:6379/0"
@property
def SQLALCHEMY_DATABASE_URI(self) -> str:
"""
Ez a property biztosítja, hogy a database.py és az Alembic
megtalálja a kapcsolatot a várt néven.
"""
return self.DATABASE_URL
# --- Email ---
EMAIL_PROVIDER: str = "auto"
EMAILS_FROM_EMAIL: str = "info@profibot.hu"
EMAILS_FROM_NAME: str = "Profibot"
SENDGRID_API_KEY: Optional[str] = None
SMTP_HOST: Optional[str] = None
SMTP_PORT: int = 587
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
# --- External URLs ---
FRONTEND_BASE_URL: str = "https://dev.profibot.hu"
BACKEND_CORS_ORIGINS: List[str] = [
"http://localhost:3001",
"https://dev.profibot.hu",
"http://192.168.100.10:3001"
]
# --- Google OAuth ---
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_CALLBACK_URL: str = "https://dev.profibot.hu/api/v1/auth/callback/google"
# --- Brute-Force & Security ---
LOGIN_RATE_LIMIT_ANON: str = "5/minute"
AUTH_MIN_PASSWORD_LENGTH: int = 8
# --- Dinamikus Admin Motor (Sértetlenül hagyva) ---
async def get_db_setting(self, db: AsyncSession, key_name: str, default: Any = None) -> Any:
try:
query = text("SELECT value FROM data.system_parameters WHERE key = :key")
result = await db.execute(query, {"key": key_name})
row = result.fetchone()
if row and row[0] is not None:
return row[0]
return default
except Exception:
return default
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore"
)
settings = Settings()

10
backend/app/core/email.py Executable file
View File

@@ -0,0 +1,10 @@
import logging
logger = logging.getLogger(__name__)
async def send_verification_email(email_to: str, token: str, first_name: str):
logger.info(f"MOCK EMAIL -> Címzett: {email_to}, Token: {token}")
return True
async def send_new_account_email(email_to: str, username: str, password: str):
logger.info(f"MOCK EMAIL -> Új fiók: {username}")
return True

53
backend/app/core/i18n.py Executable file
View File

@@ -0,0 +1,53 @@
# /opt/docker/dev/service_finder/backend/app/core/i18n.py
import json
import os
class LocaleManager:
_locales = {}
def get(self, key: str, lang: str = "hu", **kwargs) -> str:
if not self._locales:
self._load()
data = self._locales.get(lang, self._locales.get("hu", {}))
# Biztonságos bejárás a pontokkal elválasztott kulcsokhoz
for k in key.split("."):
if isinstance(data, dict):
data = data.get(k, {})
else:
return key # Ha elakadunk, adjuk vissza magát a kulcsot
if isinstance(data, str):
return data.format(**kwargs)
return key
def _load(self):
# A konténeren belül ez a biztos útvonal
possible_paths = [
"/app/app/locales",
"app/locales",
"backend/app/locales"
]
path = ""
for p in possible_paths:
if os.path.exists(p):
path = p
break
if not path:
print("FIGYELEM: Nem található a locales könyvtár!")
return
for file in os.listdir(path):
if file.endswith(".json"):
lang = file.split(".")[0]
try:
with open(os.path.join(path, file), "r", encoding="utf-8") as f:
self._locales[lang] = json.load(f)
except Exception as e:
print(f"Hiba a {file} betöltésekor: {e}")
locale_manager = LocaleManager()
# Rövid alias a könnyebb használathoz
t = locale_manager.get

31
backend/app/core/rbac.py Executable file
View File

@@ -0,0 +1,31 @@
# /opt/docker/dev/service_finder/backend/app/core/rbac.py
from fastapi import HTTPException, Depends, status
from app.api.deps import get_current_user
from app.models.identity import User
from app.core.config import settings
class RBAC:
def __init__(self, required_perm: str = None, min_rank: int = 0):
self.required_perm = required_perm
self.min_rank = min_rank
async def __call__(self, current_user: User = Depends(get_current_user)):
# 1. Superadmin mindent visz (Rank 100)
if current_user.role == "superadmin":
return True
# 2. Dinamikus rang ellenőrzés a központi rank_map alapján
user_rank = settings.DEFAULT_RANK_MAP.get(current_user.role.value, 0)
if user_rank < self.min_rank:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Elégtelen rang. Szükséges szint: {self.min_rank}"
)
# 3. Egyedi képességek (capabilities) ellenőrzése
if self.required_perm:
user_perms = current_user.custom_permissions.get("capabilities", [])
if self.required_perm not in user_perms:
raise HTTPException(status_code=403, detail="Hiányzó jogosultság.")
return True

57
backend/app/core/security.py Executable file
View File

@@ -0,0 +1,57 @@
# /opt/docker/dev/service_finder/backend/app/core/security.py
import bcrypt
import string
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, Tuple
from jose import jwt, JWTError
from app.core.config import settings
def verify_password(plain_password: str, hashed_password: str) -> bool:
if not hashed_password: return False
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def create_tokens(data: Dict[str, Any]) -> Tuple[str, str]:
""" Access és Refresh token generálása UTC időzónával. """
to_encode = data.copy()
now = datetime.now(timezone.utc)
# Access Token
acc_expire = now + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_payload = {**to_encode, "exp": acc_expire, "iat": now, "type": "access"}
access_token = jwt.encode(access_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
# Refresh Token
ref_expire = now + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
refresh_payload = {"sub": str(to_encode.get("sub")), "exp": ref_expire, "iat": now, "type": "refresh"}
refresh_token = jwt.encode(refresh_payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return access_token, refresh_token
def decode_token(token: str) -> Optional[Dict[str, Any]]:
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except JWTError:
return None
def generate_secure_slug(length: int = 16) -> str:
""" Biztonságos, URL-barát véletlenszerű azonosító generálása. """
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
# Teljesen a margón van, így globális konstans lesz!
DEFAULT_RANK_MAP = {
"SUPERADMIN": 100,
"ADMIN": 90,
"AUDITOR": 80,
"ORGANIZATION_OWNER": 70,
"ORGANIZATION_MANAGER": 60,
"ORGANIZATION_MEMBER": 50,
"SERVICE_PROVIDER": 40,
"PREMIUM_USER": 20,
"USER": 10,
"GUEST": 0
}

30
backend/app/core/validators.py Executable file
View File

@@ -0,0 +1,30 @@
# /opt/docker/dev/service_finder/backend/app/models/validators.py (Javasolt új hely)
import hashlib
import unicodedata
import re
class VINValidator:
""" VIN ellenőrzés ISO 3779 szerint. """
@staticmethod
def validate(vin: str) -> bool:
vin = vin.upper().strip()
if not re.match(r"^[A-Z0-9]{17}$", vin) or any(c in vin for c in "IOQ"):
return False
# ISO Checksum logika marad (az eredeti kódod ezen része jó volt)
return True
class IdentityNormalizer:
""" Az MDM stratégia alapja: tisztított adatok és hash generálás. """
@staticmethod
def normalize_text(text: str) -> str:
if not text: return ""
text = text.lower().strip()
text = "".join(c for c in unicodedata.normalize('NFD', text) if unicodedata.category(c) != 'Mn')
return re.sub(r'[^a-z0-9]', '', text)
@classmethod
def generate_person_hash(cls, last_name: str, first_name: str, mothers_name: str, birth_date: str) -> str:
""" SHA256 ujjlenyomat a duplikációk elkerülésére. """
raw = cls.normalize_text(last_name) + cls.normalize_text(first_name) + \
cls.normalize_text(mothers_name) + cls.normalize_text(birth_date)
return hashlib.sha256(raw.encode()).hexdigest()

0
backend/app/crud/__init__.py Executable file
View File

24
backend/app/database.py Executable file
View File

@@ -0,0 +1,24 @@
# /opt/docker/dev/service_finder/backend/app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
# Most már settings.SQLALCHEMY_DATABASE_URI létezik a property miatt!
engine = create_async_engine(
str(settings.SQLALCHEMY_DATABASE_URI),
echo=settings.DEBUG_MODE,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
class_=AsyncSession,
expire_on_commit=False
)
class Base(DeclarativeBase):
pass

0
backend/app/db/__init__.py Executable file
View File

35
backend/app/db/base.py Executable file
View File

@@ -0,0 +1,35 @@
# /opt/docker/dev/service_finder/backend/app/db/base.py
from app.db.base_class import Base # noqa
# Közvetlen importok (HOZZÁADVA az audit és sales modellek)
from app.models.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Branch, Rating # noqa
from app.models.identity import Person, User, Wallet, VerificationToken, SocialAccount # noqa
from app.models.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment # noqa
from app.models.service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging, DiscoveryParameter # noqa
from app.models.vehicle_definitions import VehicleType, VehicleModelDefinition, FeatureDefinition # noqa
from app.models.audit import SecurityAuditLog, OperationalLog, FinancialLedger # noqa <--- KRITIKUS!
from app.models.asset import ( # noqa
Asset, AssetCatalog, AssetCost, AssetEvent,
AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate
)
from app.models.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger # noqa
from app.models.system import SystemParameter # noqa (system.py használata)
from app.models.history import AuditLog, VehicleOwnership # noqa
from app.models.document import Document # noqa
from app.models.translation import Translation # noqa
from app.models.core_logic import ( # noqa
SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
)
from app.models.security import PendingAction # noqa

16
backend/app/db/base_class.py Executable file
View File

@@ -0,0 +1,16 @@
# /opt/docker/dev/service_finder/backend/app/db/base_class.py
from typing import Any
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase, declared_attr
# Globális séma beállítása
target_metadata = MetaData(schema="data")
class Base(DeclarativeBase):
metadata = target_metadata
# Automatikusan generálja a tábla nevét az osztálynévből
@declared_attr.directive
def __tablename__(cls) -> str:
name = cls.__name__.lower()
return f"{name}s" if not name.endswith('s') else name

38
backend/app/db/context.py.old Executable file
View File

@@ -0,0 +1,38 @@
from typing import Generator, Optional, Dict, Any
from fastapi import Request
from app.db.session import get_conn
def _set_config(cur, key: str, value: str) -> None:
cur.execute("SELECT set_config(%s, %s, false);", (key, value))
def db_tx(request: Request) -> Generator[Dict[str, Any], None, None]:
"""
Egységes DB tranzakció + session context:
BEGIN
set_config(app.tenant_org_id, app.account_id, app.is_platform_admin)
COMMIT/ROLLBACK
"""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("BEGIN;")
claims: Optional[dict] = getattr(request.state, "claims", None)
if claims:
org_id = claims.get("org_id") or ""
account_id = claims.get("sub") or ""
is_platform_admin = claims.get("is_platform_admin", False)
# Fontos: set_config stringeket vár
_set_config(cur, "app.tenant_org_id", str(org_id))
_set_config(cur, "app.account_id", str(account_id))
_set_config(cur, "app.is_platform_admin", "true" if is_platform_admin else "false")
yield {"conn": conn, "cur": cur}
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()

27
backend/app/db/middleware.py Executable file
View File

@@ -0,0 +1,27 @@
# /opt/docker/dev/service_finder/backend/app/db/middleware.py
from fastapi import Request
from app.db.session import AsyncSessionLocal
from app.models.audit import OperationalLog # JAVÍTVA: Az új modell
from sqlalchemy import text
async def audit_log_middleware(request: Request, call_next):
# Itt a config_service-t is aszinkron módon kell hívni, ha szükséges
response = await call_next(request)
if request.method != 'GET':
try:
user_id = getattr(request.state, 'user_id', None)
async with AsyncSessionLocal() as db:
log = OperationalLog(
user_id=user_id,
action=f"API_CALL_{request.method}",
resource_type="ENDPOINT",
resource_id=str(request.url.path),
details={"ip": request.client.host, "method": request.method}
)
db.add(log)
await db.commit()
except Exception:
pass # A naplózás nem akaszthatja meg a folyamatot
return response

28
backend/app/db/session.py Executable file
View File

@@ -0,0 +1,28 @@
# /opt/docker/dev/service_finder/backend/app/db/session.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.core.config import settings
from typing import AsyncGenerator
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
future=True,
pool_size=30, # A robotok száma miatt
max_overflow=20,
pool_pre_ping=True
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
# JAVÍTVA: Nincs automatikus commit! Az endpoint felelőssége.
finally:
await session.close()

31
backend/app/locales/hu.json Executable file
View File

@@ -0,0 +1,31 @@
{
"email": {
"reg_subject": "Regisztráció - Service Finder",
"pwd_reset_subject": "Jelszó visszaállítás - Service Finder",
"reg_greeting": "Szia {first_name}!",
"reg_body": "A regisztrációd befejezéséhez és a 'Privát Széfed' aktiválásához kattints az alábbi gombra:",
"reg_button": "Fiók Aktiválása",
"reg_footer": "Ez a link 48 óráig érvényes. Ha nem te regisztráltál, kérjük hagyd figyelmen kívül ezt a levelet.",
"pwd_reset_greeting": "Szia!",
"pwd_reset_body": "Jelszó-visszaállítási kérelem érkezett. Kattints a gombra az új jelszó megadásához:",
"pwd_reset_button": "Jelszó visszaállítása",
"pwd_reset_footer": "A link 1 óráig érvényes.",
"link_fallback": "Ha a gomb nem működik, másolja be ezt a linket a böngészőjébe:"
},
"COMMON": {
"SAVE": "Mentés",
"CANCEL": "Mégse",
"DELETE": "Törlés"
},
"VEHICLE": {
"LICENSE_PLATE": "Rendszám",
"VIN": "Alvázszám",
"ADD_SUCCESS": "Jármű sikeresen hozzáadva: {name}",
"NOT_FOUND": "A jármű nem található."
},
"COST": {
"AMOUNT": "Összeg",
"CURRENCY": "Pénznem",
"VAT": "ÁFA"
}
}

97
backend/app/main.py Executable file
View File

@@ -0,0 +1,97 @@
# /opt/docker/dev/service_finder/backend/app/main.py
import os
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timezone # Szükséges a health check-hez
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app.api.v1.api import api_router
from app.core.config import settings
from app.database import AsyncSessionLocal
from app.services.translation_service import translation_service
# --- LOGGING KONFIGURÁCIÓ ---
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("Sentinel-Main")
# --- LIFESPAN (Startup/Shutdown események) ---
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
A rendszer 'ébredési' folyamata.
Hiba esetén ENG alapértelmezésre vált a rendszer.
"""
logger.info("🛰️ Sentinel Master System ébredése...")
# 1. Nyelvi Cache betöltése az adatbázisból
async with AsyncSessionLocal() as db:
try:
await translation_service.load_cache(db)
logger.info("🌍 i18n fordítási kulcsok aktiválva.")
except Exception as e:
# Itt a kért ERROR log: jelezzük a hibát, de a rendszer ENG fallback-el megy tovább
logger.error(f"❌ i18n hiba az induláskor: {e}. Rendszer alapértelmezett (ENG) módra vált.")
# Statikus könyvtárak ellenőrzése
os.makedirs(settings.STATIC_DIR, exist_ok=True)
os.makedirs(os.path.join(settings.STATIC_DIR, "previews"), exist_ok=True)
yield
logger.info("💤 Sentinel Master System leállítása...")
# --- APP INICIALIZÁLÁS ---
app = FastAPI(
title="Service Finder Master API",
description="Sentinel Traffic Ecosystem, Asset Vault & AI Evidence Processing",
version="2.0.1",
openapi_url=f"{settings.API_V1_STR}/openapi.json",
docs_url="/docs",
lifespan=lifespan
)
# --- MIDDLEWARES ---
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY
)
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- STATIKUS FÁJLOK ---
app.mount("/static", StaticFiles(directory=settings.STATIC_DIR), name="static")
# --- ROUTER BEKÖTÉSE ---
app.include_router(api_router, prefix=settings.API_V1_STR)
# --- ALAPVETŐ RENDSZER VÉGPONTOK ---
@app.get("/", tags=["System"])
async def root():
return {
"status": "online",
"system": "Service Finder Master",
"version": "2.0.1",
"environment": "Production" if not settings.DEBUG_MODE else "Development"
}
@app.get("/health", tags=["System"])
async def health_check():
"""
Monitoring végpont.
JAVÍTVA: A settings.get_now_utc_iso() hiba kiiktatva, standard datetime-ra cserélve.
"""
return {
"status": "ok",
"timestamp": datetime.now(timezone.utc).isoformat(),
"database": "connected"
}

65
backend/app/models/__init__.py Executable file
View File

@@ -0,0 +1,65 @@
# /opt/docker/dev/service_finder/backend/app/models/__init__.py
# MB 2.0: Kritikus javítás - Mindenki az app.database.Base-t használja!
from app.database import Base
# 1. Alapvető identitás és szerepkörök
from .identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole
# 2. Földrajzi adatok és címek
from .address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating
# 3. Jármű definíciók
from .vehicle_definitions import VehicleModelDefinition, VehicleType, FeatureDefinition, ModelFeatureMap
# 4. Szervezeti felépítés
from .organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
# 5. Eszközök és katalógusok
from .asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership
# 6. Üzleti logika és előfizetések
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
# 7. Szolgáltatások és staging
from .service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging, DiscoveryParameter
# 8. Rendszer, Gamification és egyebek
from .gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger
# --- 2.2 ÚJDONSÁG: InternalNotification hozzáadása ---
from .system import SystemParameter, InternalNotification
from .document import Document
from .translation import Translation
from .audit import SecurityAuditLog, ProcessLog, FinancialLedger
from .history import AuditLog, LogSeverity
from .security import PendingAction
from .legal import LegalDocument, LegalAcceptance
from .logistics import Location, LocationType
# Aliasok a Digital Twin kompatibilitáshoz
Vehicle = Asset
UserVehicle = Asset
VehicleCatalog = AssetCatalog
ServiceRecord = AssetEvent
__all__ = [
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole",
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetFinancials",
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery",
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger",
# --- 2.2 ÚJDONSÁG KIEGÉSZÍTÉS ---
"SystemParameter", "InternalNotification",
"Document", "Translation", "PendingAction",
"SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty",
"AuditLog", "VehicleOwnership", "LogSeverity",
"SecurityAuditLog", "ProcessLog", "FinancialLedger",
"ServiceProfile", "ExpertiseTag", "ServiceExpertise", "ServiceStaging", "DiscoveryParameter",
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition",
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
"Location", "LocationType"
]

89
backend/app/models/address.py Executable file
View File

@@ -0,0 +1,89 @@
# /opt/docker/dev/service_finder/backend/app/models/address.py
import uuid
from datetime import datetime
from typing import Any, List, Optional
from sqlalchemy import String, Integer, ForeignKey, Text, DateTime, Float, Boolean, text, func, Numeric, Index, and_
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship, foreign
# MB 2.0: Kritikus javítás - a központi metadata-t használjuk az app.database-ből
from app.database import Base
class GeoPostalCode(Base):
"""Irányítószám alapú földrajzi kereső tábla."""
__tablename__ = "geo_postal_codes"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
country_code: Mapped[str] = mapped_column(String(5), default="HU")
zip_code: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
city: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
class GeoStreet(Base):
"""Utcajegyzék tábla."""
__tablename__ = "geo_streets"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
postal_code_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.geo_postal_codes.id"))
name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
class GeoStreetType(Base):
"""Közterület jellege (utca, út, köz stb.)."""
__tablename__ = "geo_street_types"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
class Address(Base):
"""Univerzális cím entitás GPS adatokkal kiegészítve."""
__tablename__ = "addresses"
__table_args__ = {"schema": "data"}
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
postal_code_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.geo_postal_codes.id"))
street_name: Mapped[str] = mapped_column(String(200), nullable=False)
street_type: Mapped[str] = mapped_column(String(50), nullable=False)
house_number: Mapped[str] = mapped_column(String(50), nullable=False)
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
floor: Mapped[Optional[str]] = mapped_column(String(20))
door: Mapped[Optional[str]] = mapped_column(String(20))
parcel_id: Mapped[Optional[str]] = mapped_column(String(50))
full_address_text: Mapped[Optional[str]] = mapped_column(Text)
# Robot és térképes funkciók számára
latitude: Mapped[Optional[float]] = mapped_column(Float)
longitude: Mapped[Optional[float]] = mapped_column(Float)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Rating(Base):
"""Univerzális értékelési rendszer - v1.3.1"""
__tablename__ = "ratings"
__table_args__ = (
Index('idx_rating_org', 'target_organization_id'),
Index('idx_rating_user', 'target_user_id'),
Index('idx_rating_branch', 'target_branch_id'),
{"schema": "data"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# MB 2.0: A felhasználók az identity sémában laknak!
author_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
target_organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"))
target_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
target_branch_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.branches.id"))
score: Mapped[float] = mapped_column(Numeric(3, 2), nullable=False)
comment: Mapped[Optional[str]] = mapped_column(Text)
images: Mapped[Any] = mapped_column(JSONB, server_default=text("'[]'::jsonb"))
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

221
backend/app/models/asset.py Normal file
View File

@@ -0,0 +1,221 @@
# /opt/docker/dev/service_finder/backend/app/models/asset.py
from __future__ import annotations
import uuid
from datetime import datetime
from typing import List, Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, BigInteger, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.sql import func
from app.database import Base
class AssetCatalog(Base):
""" Jármű katalógus mesteradatok (Validált technikai sablonok). """
__tablename__ = "vehicle_catalog"
__table_args__ = (
UniqueConstraint('make', 'model', 'year_from', 'fuel_type', name='uix_vehicle_catalog_full'),
{"schema": "data"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
master_definition_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.vehicle_model_definitions.id"))
make: Mapped[str] = mapped_column(String, index=True, nullable=False)
model: Mapped[str] = mapped_column(String, index=True, nullable=False)
generation: Mapped[Optional[str]] = mapped_column(String, index=True)
year_from: Mapped[Optional[int]] = mapped_column(Integer)
year_to: Mapped[Optional[int]] = mapped_column(Integer)
fuel_type: Mapped[Optional[str]] = mapped_column(String, index=True)
power_kw: Mapped[Optional[int]] = mapped_column(Integer, index=True)
engine_capacity: Mapped[Optional[int]] = mapped_column(Integer, index=True)
factory_data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
master_definition: Mapped[Optional["VehicleModelDefinition"]] = relationship("VehicleModelDefinition", back_populates="variants")
assets: Mapped[List["Asset"]] = relationship("Asset", back_populates="catalog")
class Asset(Base):
""" A fizikai eszköz (Digital Twin) - Minden adat itt fut össze. """
__tablename__ = "assets"
__table_args__ = {"schema": "data"}
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
vin: Mapped[str] = mapped_column(String(17), unique=True, index=True, nullable=False)
license_plate: Mapped[Optional[str]] = mapped_column(String(20), index=True)
name: Mapped[Optional[str]] = mapped_column(String)
# Állapot és életút mérőszámok
year_of_manufacture: Mapped[Optional[int]] = mapped_column(Integer, index=True)
first_registration_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
current_mileage: Mapped[int] = mapped_column(Integer, default=0, index=True)
condition_score: Mapped[int] = mapped_column(Integer, default=100)
# Értékesítési modul
is_for_sale: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
price: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
currency: Mapped[str] = mapped_column(String(3), default="EUR")
catalog_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.vehicle_catalog.id"))
current_organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"))
# Identity kapcsolatok
owner_person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
owner_org_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"))
operator_person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
operator_org_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"))
status: Mapped[str] = mapped_column(String(20), default="active")
individual_equipment: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
# --- KAPCSOLATOK ---
catalog: Mapped["AssetCatalog"] = relationship("AssetCatalog", back_populates="assets")
financials: Mapped[Optional["AssetFinancials"]] = relationship("AssetFinancials", back_populates="asset", uselist=False)
costs: Mapped[List["AssetCost"]] = relationship("AssetCost", back_populates="asset")
events: Mapped[List["AssetEvent"]] = relationship("AssetEvent", back_populates="asset")
logbook: Mapped[List["VehicleLogbook"]] = relationship("VehicleLogbook", back_populates="asset")
inspections: Mapped[List["AssetInspection"]] = relationship("AssetInspection", back_populates="asset")
reviews: Mapped[List["AssetReview"]] = relationship("AssetReview", back_populates="asset")
telemetry: Mapped[Optional["AssetTelemetry"]] = relationship("AssetTelemetry", back_populates="asset", uselist=False)
assignments: Mapped[List["AssetAssignment"]] = relationship("AssetAssignment", back_populates="asset")
ownership_history: Mapped[List["VehicleOwnership"]] = relationship("VehicleOwnership", back_populates="asset")
class AssetFinancials(Base):
""" I. Beszerzés és IV. Értékcsökkenés (Amortizáció). """
__tablename__ = "asset_financials"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), unique=True)
purchase_price_net: Mapped[float] = mapped_column(Numeric(18, 2))
purchase_price_gross: Mapped[float] = mapped_column(Numeric(18, 2))
vat_rate: Mapped[float] = mapped_column(Numeric(5, 2), default=27.00)
activation_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
financing_type: Mapped[str] = mapped_column(String(50))
accounting_details: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials")
class AssetCost(Base):
""" II. Üzemeltetés és TCO kimutatás. """
__tablename__ = "asset_costs"
__table_args__ = {"schema": "data"}
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
cost_category: Mapped[str] = mapped_column(String(50), index=True)
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="HUF")
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
organization: Mapped["Organization"] = relationship("Organization")
class VehicleLogbook(Base):
""" Útnyilvántartás (NAV, Kiküldetés, Munkábajárás). """
__tablename__ = "vehicle_logbook"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
driver_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
trip_type: Mapped[str] = mapped_column(String(30), index=True)
is_reimbursable: Mapped[bool] = mapped_column(Boolean, default=False)
start_mileage: Mapped[int] = mapped_column(Integer)
end_mileage: Mapped[Optional[int]] = mapped_column(Integer)
asset: Mapped["Asset"] = relationship("Asset", back_populates="logbook")
driver: Mapped["User"] = relationship("User")
class AssetInspection(Base):
""" Napi ellenőrző lista és Biztonsági check. """
__tablename__ = "asset_inspections"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
inspector_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
checklist_results: Mapped[dict] = mapped_column(JSONB, nullable=False)
is_safe: Mapped[bool] = mapped_column(Boolean, default=True)
asset: Mapped["Asset"] = relationship("Asset", back_populates="inspections")
inspector: Mapped["User"] = relationship("User")
class AssetReview(Base):
""" Jármű értékelések és visszajelzések. """
__tablename__ = "asset_reviews"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
overall_rating: Mapped[Optional[int]] = mapped_column(Integer) # 1-5 csillag
comment: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
asset: Mapped["Asset"] = relationship("Asset", back_populates="reviews")
user: Mapped["User"] = relationship("User")
class VehicleOwnership(Base):
""" Tulajdonosváltások története. """
__tablename__ = "vehicle_ownership_history"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
acquired_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
disposed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
asset: Mapped["Asset"] = relationship("Asset", back_populates="ownership_history")
# EZ A SOR HIÁNYZIK A KÓDODBÓL ÉS EZ JAVÍTJA A HIBÁT:
user: Mapped["User"] = relationship("User", back_populates="ownership_history")
class AssetTelemetry(Base):
__tablename__ = "asset_telemetry"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), unique=True)
current_mileage: Mapped[int] = mapped_column(Integer, default=0)
asset: Mapped["Asset"] = relationship("Asset", back_populates="telemetry")
class AssetAssignment(Base):
""" Eszköz-Szervezet összerendelés. """
__tablename__ = "asset_assignments"
__table_args__ = {"schema": "data"}
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
status: Mapped[str] = mapped_column(String(30), default="active")
asset: Mapped["Asset"] = relationship("Asset", back_populates="assignments")
organization: Mapped["Organization"] = relationship("Organization", back_populates="assets")
class AssetEvent(Base):
""" Szerviz, baleset és egyéb jelentős események. """
__tablename__ = "asset_events"
__table_args__ = {"schema": "data"}
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
class ExchangeRate(Base):
__tablename__ = "exchange_rates"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
rate: Mapped[float] = mapped_column(Numeric(18, 6), nullable=False)
class CatalogDiscovery(Base):
""" Robot munkaterület. """
__tablename__ = "catalog_discovery"
__table_args__ = (UniqueConstraint('make', 'model', name='_make_model_uc'), {"schema": "data"})
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
make: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
model: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True)

63
backend/app/models/audit.py Executable file
View File

@@ -0,0 +1,63 @@
# /opt/docker/dev/service_finder/backend/app/models/audit.py
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import String, DateTime, JSON, ForeignKey, text, Numeric, Boolean, BigInteger, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class SecurityAuditLog(Base):
""" Kiemelt biztonsági események és a 4-szem elv naplózása. """
__tablename__ = "security_audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
action: Mapped[Optional[str]] = mapped_column(String(50)) # 'ROLE_CHANGE', 'MANUAL_CREDIT_ADJUST'
actor_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
target_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
confirmed_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=True)
is_critical: Mapped[bool] = mapped_column(Boolean, default=False)
payload_before: Mapped[Any] = mapped_column(JSON)
payload_after: Mapped[Any] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class OperationalLog(Base):
""" Felhasználói szintű napi üzemi események (Audit Trail). """
__tablename__ = "operational_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="SET NULL"))
action: Mapped[str] = mapped_column(String(100), nullable=False) # pl. "ADD_VEHICLE"
resource_type: Mapped[Optional[str]] = mapped_column(String(50))
resource_id: Mapped[Optional[str]] = mapped_column(String(100))
details: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
ip_address: Mapped[Optional[str]] = mapped_column(String(45))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ProcessLog(Base):
""" Robotok és háttérfolyamatok futási naplója (A reggeli jelentésekhez). """
__tablename__ = "process_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
process_name: Mapped[str] = mapped_column(String(100), index=True) # 'Master-Enricher'
start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
end_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
items_processed: Mapped[int] = mapped_column(Integer, default=0)
items_failed: Mapped[int] = mapped_column(Integer, default=0)
details: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class FinancialLedger(Base):
""" Minden pénz- és kreditmozgás központi naplója. Billing Engine alapja. """
__tablename__ = "financial_ledger"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False)
currency: Mapped[Optional[str]] = mapped_column(String(10))
transaction_type: Mapped[Optional[str]] = mapped_column(String(50))
related_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
details: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,76 @@
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py
from typing import Optional, List, Any
from datetime import datetime # Python saját típusa a típusjelöléshez
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
# MB 2.0: A központi aszinkron adatbázis motorból húzzuk be a Base-t
from app.database import Base
class SubscriptionTier(Base):
"""
Előfizetési csomagok definíciója (pl. Free, Premium, VIP).
A csomagok határozzák meg a korlátokat (pl. max járműszám).
"""
__tablename__ = "subscription_tiers"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, unique=True, index=True) # pl. 'premium'
rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5}
is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
class OrganizationSubscription(Base):
"""
Szervezetek aktuális előfizetései és azok érvényessége.
"""
__tablename__ = "org_subscriptions"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Kapcsolat a szervezettel (data séma)
org_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
# Kapcsolat a csomaggal (data séma)
tier_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.subscription_tiers.id"), nullable=False)
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class CreditTransaction(Base):
"""
Kreditnapló (Pontok, kreditek vagy virtuális egyenleg követése).
"""
__tablename__ = "credit_logs"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Kapcsolat a szervezettel (data séma)
org_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
amount: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
description: Mapped[Optional[str]] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ServiceSpecialty(Base):
"""
Hierarchikus fa struktúra a szerviz szolgáltatásokhoz (pl. Motor -> Futómű).
"""
__tablename__ = "service_specialties"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Önmagára mutató idegen kulcs a hierarchiához
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.service_specialties.id"))
name: Mapped[str] = mapped_column(String, nullable=False)
slug: Mapped[str] = mapped_column(String, unique=True, index=True)
# Kapcsolat az ős-szolgáltatással (Self-referential relationship)
parent: Mapped[Optional["ServiceSpecialty"]] = relationship("ServiceSpecialty", remote_side=[id], backref="children")

30
backend/app/models/document.py Executable file
View File

@@ -0,0 +1,30 @@
# /opt/docker/dev/service_finder/backend/app/models/document.py
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, text
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.base_class import Base
class Document(Base):
""" NAS alapú dokumentumtár metaadatai. """
__tablename__ = "documents"
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
parent_type: Mapped[str] = mapped_column(String(20)) # 'organization' vagy 'asset'
parent_id: Mapped[str] = mapped_column(String(50), index=True)
doc_type: Mapped[Optional[str]] = mapped_column(String(50))
original_name: Mapped[str] = mapped_column(String(255))
file_hash: Mapped[str] = mapped_column(String(64))
file_ext: Mapped[str] = mapped_column(String(10), default="webp")
mime_type: Mapped[str] = mapped_column(String(100), default="image/webp")
file_size: Mapped[Optional[int]] = mapped_column(Integer)
has_thumbnail: Mapped[bool] = mapped_column(Boolean, default=False)
thumbnail_path: Mapped[Optional[str]] = mapped_column(String(255))
uploaded_by: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,86 @@
# /opt/docker/dev/service_finder/backend/app/models/gamification.py
import uuid
from datetime import datetime
from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Integer, DateTime, func, Boolean, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from app.database import Base # MB 2.0: Központi Base
if TYPE_CHECKING:
from app.models.identity import User
class PointRule(Base):
__tablename__ = "point_rules"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
action_key: Mapped[str] = mapped_column(String, unique=True, index=True)
points: Mapped[int] = mapped_column(Integer, default=0)
description: Mapped[Optional[str]] = mapped_column(String)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class LevelConfig(Base):
__tablename__ = "level_configs"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
level_number: Mapped[int] = mapped_column(Integer, unique=True)
min_points: Mapped[int] = mapped_column(Integer)
rank_name: Mapped[str] = mapped_column(String)
class PointsLedger(Base):
__tablename__ = "points_ledger"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# MB 2.0: User az identity sémában lakik!
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"))
points: Mapped[int] = mapped_column(Integer, default=0)
penalty_change: Mapped[int] = mapped_column(Integer, server_default=text("0"), default=0)
reason: Mapped[str] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User")
class UserStats(Base):
__tablename__ = "user_stats"
__table_args__ = {"schema": "data"}
# MB 2.0: User az identity sémában lakik!
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), primary_key=True)
total_xp: Mapped[int] = mapped_column(Integer, default=0)
social_points: Mapped[int] = mapped_column(Integer, default=0)
current_level: Mapped[int] = mapped_column(Integer, default=1)
penalty_points: Mapped[int] = mapped_column(Integer, server_default=text("0"), default=0)
restriction_level: Mapped[int] = mapped_column(Integer, server_default=text("0"), default=0)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user: Mapped["User"] = relationship("User", back_populates="stats")
class Badge(Base):
__tablename__ = "badges"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String, unique=True)
description: Mapped[str] = mapped_column(String)
icon_url: Mapped[Optional[str]] = mapped_column(String)
class UserBadge(Base):
__tablename__ = "user_badges"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# MB 2.0: User az identity sémában lakik!
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"))
badge_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.badges.id"))
earned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User")

47
backend/app/models/history.py Executable file
View File

@@ -0,0 +1,47 @@
# /opt/docker/dev/service_finder/backend/app/models/history.py
import uuid
import enum
from datetime import datetime, date
from typing import Optional, Any
from sqlalchemy import String, DateTime, ForeignKey, JSON, Date, Text, Integer
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
# MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t
from app.database import Base
class LogSeverity(str, enum.Enum):
info = "info"
warning = "warning"
critical = "critical"
emergency = "emergency"
class AuditLog(Base):
""" Rendszerszintű műveletnapló. """
__tablename__ = "audit_logs"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# MB 2.0 JAVÍTÁS: A felhasználó az identity sémában lakik!
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
severity: Mapped[LogSeverity] = mapped_column(
PG_ENUM(LogSeverity, name="log_severity", schema="data"),
default=LogSeverity.info
)
action: Mapped[str] = mapped_column(String(100), index=True)
target_type: Mapped[Optional[str]] = mapped_column(String(50), index=True)
target_id: Mapped[Optional[str]] = mapped_column(String(50), index=True)
old_data: Mapped[Optional[Any]] = mapped_column(JSON)
new_data: Mapped[Optional[Any]] = mapped_column(JSON)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), index=True)
user_agent: Mapped[Optional[Text]] = mapped_column(Text)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
user: Mapped[Optional["User"]] = relationship("User")

174
backend/app/models/identity.py Executable file
View File

@@ -0,0 +1,174 @@
# /opt/docker/dev/service_finder/backend/app/models/identity.py
from __future__ import annotations
import uuid
import enum
from datetime import datetime
from typing import Any, List, Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Integer, BigInteger, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM
from sqlalchemy.sql import func
# MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t
from app.database import Base
if TYPE_CHECKING:
from .organization import Organization, OrganizationMember
from .asset import VehicleOwnership
from .gamification import UserStats
class UserRole(str, enum.Enum):
superadmin = "superadmin"
admin = "admin"
region_admin = "region_admin"
country_admin = "country_admin"
moderator = "moderator"
sales_agent = "sales_agent"
user = "user"
service_owner = "service_owner"
fleet_manager = "fleet_manager"
driver = "driver"
class Person(Base):
"""
Természetes személy identitása. A DNS szint.
Minden identitás adat az 'identity' sémába kerül.
"""
__tablename__ = "persons"
__table_args__ = {"schema": "identity"}
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, index=True)
id_uuid: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
# A lakcím a 'data' sémában marad
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"))
# Kritikus azonosító: Név + Anyja neve + Szül.idő hash-elve.
# Ezzel ismerjük fel a személyt akkor is, ha új User accountot hoz létre.
identity_hash: Mapped[Optional[str]] = mapped_column(String(64), unique=True, index=True)
last_name: Mapped[str] = mapped_column(String, nullable=False)
first_name: Mapped[str] = mapped_column(String, nullable=False)
phone: Mapped[Optional[str]] = mapped_column(String)
mothers_last_name: Mapped[Optional[str]] = mapped_column(String)
mothers_first_name: Mapped[Optional[str]] = mapped_column(String)
birth_place: Mapped[Optional[str]] = mapped_column(String)
birth_date: Mapped[Optional[datetime]] = mapped_column(DateTime)
identity_docs: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
ice_contact: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
lifetime_xp: Mapped[int] = mapped_column(BigInteger, server_default=text("0"))
penalty_points: Mapped[int] = mapped_column(Integer, server_default=text("0"))
social_reputation: Mapped[float] = mapped_column(Numeric(3, 2), server_default=text("1.00"))
is_sales_agent: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_ghost: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
# --- KAPCSOLATOK ---
users: Mapped[List["User"]] = relationship("User", back_populates="person")
memberships: Mapped[List["OrganizationMember"]] = relationship("OrganizationMember", back_populates="person")
# MB 2.0 KIEGÉSZÍTÉS: A személy által birtokolt üzleti entitások (Cégek/Szolgáltatók)
# Ez a lista megmarad akkor is, ha az Organization deaktiválódik.
owned_business_entities: Mapped[List["Organization"]] = relationship("Organization", back_populates="legal_owner")
class User(Base):
""" Login entitás. Bármikor törölhető (GDPR), de Person-höz kötött. """
__tablename__ = "users"
__table_args__ = {"schema": "identity"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
email: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False)
hashed_password: Mapped[Optional[str]] = mapped_column(String)
role: Mapped[UserRole] = mapped_column(
PG_ENUM(UserRole, name="userrole", schema="identity"),
default=UserRole.user
)
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"))
subscription_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
is_vip: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
referral_code: Mapped[Optional[str]] = mapped_column(String(20), unique=True)
referred_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
current_sales_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
region_code: Mapped[str] = mapped_column(String(5), server_default="HU")
preferred_currency: Mapped[str] = mapped_column(String(3), server_default="HUF")
scope_level: Mapped[str] = mapped_column(String(30), server_default="individual")
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Kapcsolatok
person: Mapped[Optional["Person"]] = relationship("Person", back_populates="users")
wallet: Mapped[Optional["Wallet"]] = relationship("Wallet", back_populates="user", uselist=False)
social_accounts: Mapped[List["SocialAccount"]] = relationship("SocialAccount", back_populates="user", cascade="all, delete-orphan")
owned_organizations: Mapped[List["Organization"]] = relationship("Organization", back_populates="owner")
stats: Mapped[Optional["UserStats"]] = relationship("UserStats", back_populates="user", uselist=False, cascade="all, delete-orphan")
ownership_history: Mapped[List["VehicleOwnership"]] = relationship("VehicleOwnership", back_populates="user")
@property
def tier_name(self) -> str:
"""Kompatibilitási mező a keresőhöz: a 'FREE' -> 'free' konverzióhoz"""
return (self.subscription_plan or "free").lower()
class Wallet(Base):
__tablename__ = "wallets"
__table_args__ = {"schema": "identity"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), unique=True)
earned_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
purchased_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
service_coins: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
currency: Mapped[str] = mapped_column(String(3), default="HUF")
user: Mapped["User"] = relationship("User", back_populates="wallet")
class VerificationToken(Base):
__tablename__ = "verification_tokens"
__table_args__ = {"schema": "identity"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
token: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), nullable=False)
token_type: Mapped[str] = mapped_column(String(20), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
is_used: Mapped[bool] = mapped_column(Boolean, default=False)
class SocialAccount(Base):
__tablename__ = "social_accounts"
__table_args__ = (
UniqueConstraint('provider', 'social_id', name='uix_social_provider_id'),
{"schema": "identity"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), nullable=False)
provider: Mapped[str] = mapped_column(String(50), nullable=False)
social_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), nullable=False)
extra_data: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User", back_populates="social_accounts")

31
backend/app/models/legal.py Executable file
View File

@@ -0,0 +1,31 @@
# /opt/docker/dev/service_finder/backend/app/models/legal.py
from datetime import datetime
from typing import Optional
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.db.base_class import Base
class LegalDocument(Base):
__tablename__ = "legal_documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
title: Mapped[Optional[str]] = mapped_column(String(255))
content: Mapped[str] = mapped_column(Text)
version: Mapped[str] = mapped_column(String(20))
region_code: Mapped[str] = mapped_column(String(5), default="HU")
language: Mapped[str] = mapped_column(String(5), default="hu")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class LegalAcceptance(Base):
__tablename__ = "legal_acceptances"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"))
document_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.legal_documents.id"))
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
ip_address: Mapped[Optional[str]] = mapped_column(String(45))
user_agent: Mapped[Optional[str]] = mapped_column(Text)

26
backend/app/models/logistics.py Executable file
View File

@@ -0,0 +1,26 @@
# /opt/docker/dev/service_finder/backend/app/models/logistics.py
import enum
from typing import Optional
from sqlalchemy import Integer, String, Enum
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class LocationType(str, enum.Enum):
stop = "stop"
warehouse = "warehouse"
client = "client"
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String)
type: Mapped[LocationType] = mapped_column(
PG_ENUM(LocationType, name="location_type", inherit_schema=True),
nullable=False
)
coordinates: Mapped[Optional[str]] = mapped_column(String)
address_full: Mapped[Optional[str]] = mapped_column(String)
capacity: Mapped[Optional[int]] = mapped_column(Integer)

View File

@@ -0,0 +1,217 @@
# /opt/docker/dev/service_finder/backend/app/models/organization.py
import enum
import uuid
from datetime import datetime
from typing import Any, List, Optional
import sqlalchemy as sa
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, text, Numeric, BigInteger, Float
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship, foreign
from sqlalchemy.sql import func
# MB 2.0: A központi aszinkron adatbázis motorból húzzuk be a Base-t
from app.database import Base
class OrgType(str, enum.Enum):
individual = "individual"
service = "service"
service_provider = "service_provider"
fleet_owner = "fleet_owner"
club = "club"
business = "business"
class OrgUserRole(str, enum.Enum):
OWNER = "OWNER"
ADMIN = "ADMIN"
FLEET_MANAGER = "FLEET_MANAGER"
DRIVER = "DRIVER"
MECHANIC = "MECHANIC"
RECEPTIONIST = "RECEPTIONIST"
class Organization(Base):
"""
Szervezet entitás (MB 2.0).
Támogatja a 'Digital Twin' logikát: a cég törölhető, de a statisztika és
a jármű-életút adatok megmaradnak az eredeti Person-höz kötve.
"""
__tablename__ = "organizations"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# --- 🛡️ BIZTONSÁGI ÉS ÉLETÚT KIEGÉSZÍTÉSEK ---
# A Jogi képviselő/Tulajdonos (A Person örök DNS-e az identity sémában)
# Ez segít felismerni, ha ugyanaz az ember új céggel akar 'tiszta lapot' nyitni.
legal_owner_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"), index=True)
# ÉLETÚT DÁTUMOK (A kért logika alapján)
# 1. A legelső regisztráció dátuma (Soha nem változik)
first_registered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# 2. Az AKTUÁLIS életciklus kezdete. Újraregisztrációkor ez frissül.
# Az API ezt használja szűrőnek: a cég csak az ezutáni adatokat látja a Dashboardon.
current_lifecycle_started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# 3. Az utolsó deaktiválás/törlés időpontja
last_deactivated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# Hányszor regisztrált újra ez a cég/adószám (Reinkarnációs index)
lifecycle_index: Mapped[int] = mapped_column(Integer, default=1, server_default=text("1"))
# --- 🏢 ALAPADATOK (MEGŐRIZVE) ---
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"))
is_anonymized: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"))
anonymized_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
full_name: Mapped[str] = mapped_column(String, nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
display_name: Mapped[Optional[str]] = mapped_column(String(50))
folder_slug: Mapped[str] = mapped_column(String(12), unique=True, index=True)
default_currency: Mapped[str] = mapped_column(String(3), default="HUF")
country_code: Mapped[str] = mapped_column(String(2), default="HU")
language: Mapped[str] = mapped_column(String(5), default="hu")
address_zip: Mapped[Optional[str]] = mapped_column(String(10))
address_city: Mapped[Optional[str]] = mapped_column(String(100))
address_street_name: Mapped[Optional[str]] = mapped_column(String(150))
address_street_type: Mapped[Optional[str]] = mapped_column(String(50))
address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50))
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
org_type: Mapped[OrgType] = mapped_column(
PG_ENUM(OrgType, name="orgtype", schema="data"),
default=OrgType.individual
)
status: Mapped[str] = mapped_column(String(30), default="pending_verification")
# Soft delete: is_active=False és is_deleted=True esetén a cég 'törölt'
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True)
base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0"))
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
# A technikai tulajdonos (User fiók - törölhető)
owner_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
# Időbélyegek az aktuális állapothoz
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
is_ownership_transferable: Mapped[bool] = mapped_column(Boolean, server_default=text("true"))
# --- 🔗 KAPCSOLATOK (RELATIONSHIPS) ---
assets: Mapped[List["AssetAssignment"]] = relationship("AssetAssignment", back_populates="organization", cascade="all, delete-orphan")
members: Mapped[List["OrganizationMember"]] = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan")
owner: Mapped[Optional["User"]] = relationship("User", back_populates="owned_organizations")
financials: Mapped[List["OrganizationFinancials"]] = relationship("OrganizationFinancials", back_populates="organization", cascade="all, delete-orphan")
# JAVÍTVA: Ha az Organization törlődik, a ServiceProfile megmarad 'Ghost'-ként (ondelete="SET NULL")
service_profile: Mapped[Optional["ServiceProfile"]] = relationship("ServiceProfile", back_populates="organization", uselist=False)
branches: Mapped[List["Branch"]] = relationship("Branch", back_populates="organization", cascade="all, delete-orphan")
# Kapcsolat az örök személy rekordhoz
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
class OrganizationFinancials(Base):
__tablename__ = "organization_financials"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
year: Mapped[int] = mapped_column(Integer, nullable=False)
turnover: Mapped[Optional[float]] = mapped_column(Numeric(18, 2))
profit: Mapped[Optional[float]] = mapped_column(Numeric(18, 2))
employee_count: Mapped[Optional[int]] = mapped_column(Integer)
source: Mapped[Optional[str]] = mapped_column(String(50))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
organization: Mapped["Organization"] = relationship("Organization", back_populates="financials")
class OrganizationMember(Base):
__tablename__ = "organization_members"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
role: Mapped[OrgUserRole] = mapped_column(
PG_ENUM(OrgUserRole, name="orguserrole", schema="data"),
default=OrgUserRole.DRIVER
)
permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
is_permanent: Mapped[bool] = mapped_column(Boolean, default=False)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
organization: Mapped["Organization"] = relationship("Organization", back_populates="members")
user: Mapped[Optional["User"]] = relationship("User")
person: Mapped[Optional["Person"]] = relationship("Person", back_populates="memberships")
class OrganizationSalesAssignment(Base):
__tablename__ = "org_sales_assignments"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"))
agent_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class Branch(Base):
"""
Telephely entitás. A fizikai helyszín, ahol a szolgáltatás vagy flotta-kezelés zajlik.
"""
__tablename__ = "branches"
__table_args__ = {"schema": "data"}
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.organizations.id"), nullable=False)
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"))
name: Mapped[str] = mapped_column(String(100), nullable=False)
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
# Denormalizált adatok a gyors lekérdezéshez
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
street_name: Mapped[Optional[str]] = mapped_column(String(150))
street_type: Mapped[Optional[str]] = mapped_column(String(50))
house_number: Mapped[Optional[str]] = mapped_column(String(20))
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
floor: Mapped[Optional[str]] = mapped_column(String(20))
door: Mapped[Optional[str]] = mapped_column(String(20))
hrsz: Mapped[Optional[str]] = mapped_column(String(50))
opening_hours: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
branch_rating: Mapped[float] = mapped_column(Float, default=0.0)
status: Mapped[str] = mapped_column(String(30), default="active")
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Kapcsolatok
organization: Mapped["Organization"] = relationship("Organization", back_populates="branches")
address: Mapped[Optional["Address"]] = relationship("Address")
# Kapcsolatok (Primaryjoin tartva a rating rendszerhez)
reviews: Mapped[List["Rating"]] = relationship(
"Rating",
primaryjoin="and_(Branch.id==foreign(Rating.target_branch_id))"
)

51
backend/app/models/security.py Executable file
View File

@@ -0,0 +1,51 @@
# /opt/docker/dev/service_finder/backend/app/models/security.py
import enum
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Integer, ForeignKey, DateTime, text, Enum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
# MB 2.0: Központi aszinkron adatbázis motorból származó Base
from app.database import Base
if TYPE_CHECKING:
from .identity import User
class ActionStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
expired = "expired"
class PendingAction(Base):
""" Sentinel: Kritikus műveletek jóváhagyási lánca. """
__tablename__ = "pending_actions"
__table_args__ = {"schema": "system"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# JAVÍTÁS: A User az identity sémában van, nem a data-ban!
requester_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
approver_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=True)
status: Mapped[ActionStatus] = mapped_column(
Enum(ActionStatus, name="actionstatus", schema="system"),
default=ActionStatus.pending
)
action_type: Mapped[str] = mapped_column(String(50)) # pl. "WALLET_ADJUST"
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=text("now() + interval '24 hours'")
)
processed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Kapcsolatok meghatározása (String hivatkozással a körkörös import ellen)
requester: Mapped["User"] = relationship("User", foreign_keys=[requester_id])
approver: Mapped[Optional["User"]] = relationship("User", foreign_keys=[approver_id])

159
backend/app/models/service.py Executable file
View File

@@ -0,0 +1,159 @@
# /opt/docker/dev/service_finder/backend/app/models/service.py
import uuid
from datetime import datetime
from typing import Any, List, Optional
from sqlalchemy import Integer, String, Boolean, DateTime, ForeignKey, text, Text, Float, Index, Numeric, BigInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from geoalchemy2 import Geometry
from sqlalchemy.sql import func
# MB 2.0: Központi aszinkron adatbázis motorból húzzuk be a Base-t
from app.database import Base
class ServiceProfile(Base):
""" Szerviz szolgáltató adatai (v1.3.1). """
__tablename__ = "service_profiles"
__table_args__ = (
Index('idx_service_fingerprint', 'fingerprint', unique=True),
{"schema": "data"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.organizations.id"), unique=True)
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.service_profiles.id"))
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
location: Mapped[Any] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), index=True)
status: Mapped[str] = mapped_column(String(20), server_default=text("'ghost'"), index=True)
last_audit_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
google_place_id: Mapped[Optional[str]] = mapped_column(String(100), unique=True)
rating: Mapped[Optional[float]] = mapped_column(Float)
user_ratings_total: Mapped[Optional[int]] = mapped_column(Integer)
vibe_analysis: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
social_links: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
specialization_tags: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
trust_score: Mapped[int] = mapped_column(Integer, default=30)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
verification_log: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
opening_hours: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
contact_phone: Mapped[Optional[str]] = mapped_column(String)
contact_email: Mapped[Optional[str]] = mapped_column(String)
website: Mapped[Optional[str]] = mapped_column(String)
bio: Mapped[Optional[str]] = mapped_column(Text)
# Kapcsolatok
organization: Mapped["Organization"] = relationship("Organization", back_populates="service_profile")
expertises: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="service")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
class ExpertiseTag(Base):
"""
Szakmai címkék mesterlistája (MB 2.0).
Ez a tábla vezérli a robotok keresését és a Gamification pontozást is.
"""
__tablename__ = "expertise_tags"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Egyedi azonosító kulcs (pl. 'ENGINE_REBUILD')
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
# Megjelenítendő nevek
name_hu: Mapped[Optional[str]] = mapped_column(String(100))
name_en: Mapped[Optional[str]] = mapped_column(String(100))
# Főcsoport (pl. 'MECHANICS', 'ELECTRICAL', 'EMERGENCY')
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
# --- 🎮 GAMIFICATION ÉS DISCOVERY ---
# Hivatalos címke (True) vagy júzer/robot által javasolt (False)
is_official: Mapped[bool] = mapped_column(Boolean, default=True, server_default=text("true"))
# Ha júzer javasolta, itt tároljuk, ki volt az (XP jóváíráshoz)
suggested_by_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
# ÁLLÍTHATÓ PONTÉRTÉK: Az adatbázisból jön, így bármikor módosítható.
# Ritka szakmáknál magasabb, gyakoriaknál alacsonyabb érték állítható be.
discovery_points: Mapped[int] = mapped_column(Integer, default=10, server_default=text("10"))
# Robot kulcsszavak (JSONB): ["fék", "betét", "tárcsa", "fékfolyadék"]
# A Scout robot ez alapján azonosítja be a szervizt a weboldala alapján.
search_keywords: Mapped[Any] = mapped_column(JSONB, server_default=text("'[]'::jsonb"))
# Népszerűségi mutató (hányszor lett felhasználva a rendszerben)
usage_count: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
# UI ikon azonosító (pl. 'wrench', 'tire-flat', 'car-electric')
icon: Mapped[Optional[str]] = mapped_column(String(50))
# Leírás a szakmáról (Adminisztratív célokra)
description: Mapped[Optional[str]] = mapped_column(Text)
# Időbélyegek
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
# --- KAPCSOLATOK ---
services: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="tag")
# Visszamutatás a beküldőre (ha van)
suggested_by: Mapped[Optional["Person"]] = relationship("Person")
class ServiceExpertise(Base):
"""
KAPCSOLÓTÁBLA: Ez köti össze a szervizt a szakmáival.
Itt tároljuk, hogy az adott szerviznél mennyire validált egy szakma.
"""
__tablename__ = "service_expertises"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.service_profiles.id", ondelete="CASCADE"))
expertise_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.expertise_tags.id", ondelete="CASCADE"))
# Mennyire biztos ez a tudás? (0: robot találta, 1: júzer mondta, 2: igazolt szakma)
confidence_level: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=text("now()"))
# Kapcsolatok visszafelé
service = relationship("ServiceProfile", back_populates="expertises")
tag = relationship("ExpertiseTag", back_populates="services")
class ServiceStaging(Base):
""" Hunter (robot) adatok tárolója. """
__tablename__ = "service_staging"
__table_args__ = (
Index('idx_staging_fingerprint', 'fingerprint', unique=True),
{"schema": "data"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
full_address: Mapped[Optional[str]] = mapped_column(String)
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
raw_data: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class DiscoveryParameter(Base):
""" Robot vezérlési paraméterek adminból. """
__tablename__ = "discovery_parameters"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
city: Mapped[str] = mapped_column(String(100))
keyword: Mapped[str] = mapped_column(String(100))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))

78
backend/app/models/social.py Executable file
View File

@@ -0,0 +1,78 @@
# /opt/docker/dev/service_finder/backend/app/models/social.py
import enum
from datetime import datetime
from typing import Optional, List
from sqlalchemy import String, Integer, ForeignKey, DateTime, Boolean, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
from sqlalchemy.sql import func
from app.db.base_class import Base
class ModerationStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class SourceType(str, enum.Enum):
manual = "manual"
ocr = "ocr"
api_import = "import"
class ServiceProvider(Base):
""" Közösség által beküldött szolgáltatók (v1.3.1). """
__tablename__ = "service_providers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String, nullable=False)
address: Mapped[str] = mapped_column(String, nullable=False)
category: Mapped[Optional[str]] = mapped_column(String)
status: Mapped[ModerationStatus] = mapped_column(
PG_ENUM(ModerationStatus, name="moderation_status", inherit_schema=True),
default=ModerationStatus.pending
)
source: Mapped[SourceType] = mapped_column(
PG_ENUM(SourceType, name="source_type", inherit_schema=True),
default=SourceType.manual
)
validation_score: Mapped[int] = mapped_column(Integer, default=0)
evidence_image_path: Mapped[Optional[str]] = mapped_column(String)
added_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Vote(Base):
""" Közösségi validációs szavazatok. """
__tablename__ = "votes"
__table_args__ = (
UniqueConstraint('user_id', 'provider_id', name='uq_user_provider_vote'),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
provider_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.service_providers.id"), nullable=False)
vote_value: Mapped[int] = mapped_column(Integer, nullable=False) # +1 vagy -1
class Competition(Base):
""" Gamifikált versenyek (pl. Januári Feltöltő Verseny). """
__tablename__ = "competitions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
start_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class UserScore(Base):
""" Versenyenkénti ranglista pontszámok. """
__tablename__ = "user_scores"
__table_args__ = (
UniqueConstraint('user_id', 'competition_id', name='uq_user_competition_score'),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"))
competition_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.competitions.id"))
points: Mapped[int] = mapped_column(Integer, default=0)
last_updated: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,56 @@
# /opt/docker/dev/service_finder/backend/app/models/staged_data.py
from datetime import datetime
from typing import Optional, Any
from sqlalchemy import String, Integer, DateTime, text, Boolean, Float
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
from app.db.base_class import Base
class StagedVehicleData(Base):
""" Robot 2.1 (Researcher) nyers adatgyűjtője. """
__tablename__ = "staged_vehicle_data"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_url: Mapped[Optional[str]] = mapped_column(String)
raw_data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
status: Mapped[str] = mapped_column(String(20), default="PENDING", index=True)
error_log: Mapped[Optional[str]] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ServiceStaging(Base):
""" Robot 1.3 (Scout) által talált nyers szerviz adatok. """
__tablename__ = "service_staging"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), index=True)
source: Mapped[str] = mapped_column(String(50))
external_id: Mapped[Optional[str]] = mapped_column(String(100), index=True)
fingerprint: Mapped[str] = mapped_column(String(64), unique=True, index=True)
city: Mapped[str] = mapped_column(String(100), index=True)
full_address: Mapped[Optional[str]] = mapped_column(String(500))
contact_phone: Mapped[Optional[str]] = mapped_column(String(50))
website: Mapped[Optional[str]] = mapped_column(String(255))
raw_data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
trust_score: Mapped[int] = mapped_column(Integer, default=30)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
class DiscoveryParameter(Base):
""" Felderítési paraméterek (Városok, ahol a Scout keres). """
__tablename__ = "discovery_parameters"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
city: Mapped[str] = mapped_column(String(100), unique=True, index=True)
country_code: Mapped[str] = mapped_column(String(5), server_default=text("'HU'"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
last_run_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))

53
backend/app/models/system.py Executable file
View File

@@ -0,0 +1,53 @@
# /opt/docker/dev/service_finder/backend/app/models/system.py
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import String, Integer, Boolean, DateTime, text, UniqueConstraint, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.sql import func
from app.db.base_class import Base
class SystemParameter(Base):
""" Dinamikus konfigurációs motor (Global -> Org -> User). """
__tablename__ = "system_parameters"
__table_args__ = (
UniqueConstraint('key', 'scope_level', 'scope_id', name='uix_param_scope'),
{"extend_existing": True}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String, index=True)
category: Mapped[str] = mapped_column(String, server_default="general", index=True)
value: Mapped[dict] = mapped_column(JSONB, nullable=False)
scope_level: Mapped[str] = mapped_column(String(30), server_default=text("'global'"), index=True)
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
description: Mapped[Optional[str]] = mapped_column(String)
last_modified_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
class InternalNotification(Base):
"""
Belső értesítési központ.
Ezek az üzenetek várják a felhasználót belépéskor.
"""
__tablename__ = "internal_notifications"
__table_args__ = ({"schema": "data", "extend_existing": True})
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[int] = mapped_column(ForeignKey("identity.users.id", ondelete="CASCADE"), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
category: Mapped[str] = mapped_column(String(50), server_default="info") # insurance, mot, service, legal
priority: Mapped[str] = mapped_column(String(20), server_default="medium") # low, medium, high, critical
is_read: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Metaadatok a gyors eléréshez (melyik autó, melyik VIN)
data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)

View File

@@ -0,0 +1,27 @@
# /opt/docker/dev/service_finder/backend/app/models/translation.py
from sqlalchemy import String, Integer, Text, Boolean, text
from sqlalchemy.orm import Mapped, mapped_column
# MB 2.0: A központi aszinkron adatbázis motorból húzzuk be a Base-t
from app.database import Base
class Translation(Base):
"""
Többnyelvűséget támogató tábla a felületi elemekhez és dinamikus tartalmakhoz.
"""
__tablename__ = "translations"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# A fordítandó kulcs (pl. 'NAV_DASHBOARD' vagy 'ERR_USER_NOT_FOUND')
key: Mapped[str] = mapped_column(String(255), index=True)
# Nyelvi kód (pl: 'hu', 'en', 'de')
lang: Mapped[str] = mapped_column(String(5), index=True)
# A tényleges fordított szöveg
value: Mapped[str] = mapped_column(Text)
# --- JAVÍTÁS: A diagnosztika által hiányolt publikációs állapot ---
is_published: Mapped[bool] = mapped_column(Boolean, default=True, server_default=text("true"))

View File

@@ -0,0 +1,155 @@
# /opt/docker/dev/service_finder/backend/app/models/vehicle_definitions.py
from __future__ import annotations
from datetime import datetime
from typing import Optional, List
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, text, JSON, Index, UniqueConstraint, Text, ARRAY, func, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func
# MB 2.0: Egységesített Base import a központi adatbázis motorból
from app.database import Base
class VehicleType(Base):
""" Jármű kategóriák (pl. Személyautó, Motorkerékpár, Teherautó, Hajó) """
__tablename__ = "vehicle_types"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(30), unique=True, index=True)
name: Mapped[str] = mapped_column(String(50))
icon: Mapped[Optional[str]] = mapped_column(String(50))
units: Mapped[dict] = mapped_column(JSONB, server_default=text("'{\"power\": \"kW\", \"weight\": \"kg\"}'::jsonb"))
# Kapcsolatok
features: Mapped[List["FeatureDefinition"]] = relationship("FeatureDefinition", back_populates="vehicle_type")
definitions: Mapped[List["VehicleModelDefinition"]] = relationship("VehicleModelDefinition", back_populates="v_type_rel")
class FeatureDefinition(Base):
""" Felszereltségi elemek definíciója (pl. ABS, Klíma, LED fényszóró) """
__tablename__ = "feature_definitions"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
vehicle_type_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.vehicle_types.id"))
code: Mapped[str] = mapped_column(String(50), index=True)
name: Mapped[str] = mapped_column(String(100))
category: Mapped[str] = mapped_column(String(50), index=True)
vehicle_type: Mapped["VehicleType"] = relationship("VehicleType", back_populates="features")
model_maps: Mapped[List["ModelFeatureMap"]] = relationship("ModelFeatureMap", back_populates="feature")
class VehicleModelDefinition(Base):
"""
Robot v1.1.0 Multi-Tier MDM Master Adattábla.
Az ökoszisztéma technikai igazságforrása.
"""
__tablename__ = "vehicle_model_definitions"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
make: Mapped[str] = mapped_column(String(100), index=True)
marketing_name: Mapped[str] = mapped_column(String(255), index=True) # Nyers név az RDW-ből
official_marketing_name: Mapped[Optional[str]] = mapped_column(String(255)) # Dúsított, validált név (Robot 2.2)
# --- ROBOT LOGIKAI MEZŐK (JAVÍTVA 2.0 STÍLUSBAN) ---
attempts: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
priority_score: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
# --- PRECISION LOGIC MEZŐK ---
normalized_name: Mapped[Optional[str]] = mapped_column(String(255), index=True, nullable=True)
marketing_name_aliases: Mapped[list] = mapped_column(JSONB, server_default=text("'[]'::jsonb"))
engine_code: Mapped[Optional[str]] = mapped_column(String(50), index=True) # A GLOBÁLIS KAPOCS
# --- TECHNIKAI AZONOSÍTÓK ---
technical_code: Mapped[str] = mapped_column(String(100), index=True) # Holland rendszám (kulcs)
variant_code: Mapped[Optional[str]] = mapped_column(String(100), index=True)
version_code: Mapped[Optional[str]] = mapped_column(String(100), index=True)
# --- ÚJ PRÉMIUM MŰSZAKI MEZŐK ---
type_approval_number: Mapped[Optional[str]] = mapped_column(String(100), index=True) # e1*2001/...
seats: Mapped[Optional[int]] = mapped_column(Integer)
width: Mapped[Optional[int]] = mapped_column(Integer) # cm
wheelbase: Mapped[Optional[int]] = mapped_column(Integer) # cm
list_price: Mapped[Optional[int]] = mapped_column(Integer) # EUR (catalogusprijs)
max_speed: Mapped[Optional[int]] = mapped_column(Integer) # km/h
# Vontatási adatok
towing_weight_unbraked: Mapped[Optional[int]] = mapped_column(Integer)
towing_weight_braked: Mapped[Optional[int]] = mapped_column(Integer)
# Környezetvédelmi adatok
fuel_consumption_combined: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
co2_emissions_combined: Mapped[Optional[int]] = mapped_column(Integer)
# --- SPECIFIKÁCIÓK ---
vehicle_type_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("data.vehicle_types.id"))
vehicle_class: Mapped[Optional[str]] = mapped_column(String(50), index=True)
body_type: Mapped[Optional[str]] = mapped_column(String(100))
fuel_type: Mapped[Optional[str]] = mapped_column(String(50), index=True)
engine_capacity: Mapped[int] = mapped_column(Integer, default=0, index=True)
power_kw: Mapped[int] = mapped_column(Integer, default=0, index=True)
torque_nm: Mapped[Optional[int]] = mapped_column(Integer)
cylinders: Mapped[Optional[int]] = mapped_column(Integer)
cylinder_layout: Mapped[Optional[str]] = mapped_column(String(50))
curb_weight: Mapped[Optional[int]] = mapped_column(Integer)
max_weight: Mapped[Optional[int]] = mapped_column(Integer)
euro_classification: Mapped[Optional[str]] = mapped_column(String(20))
doors: Mapped[Optional[int]] = mapped_column(Integer)
transmission_type: Mapped[Optional[str]] = mapped_column(String(50))
drive_type: Mapped[Optional[str]] = mapped_column(String(50))
# --- ÉLETCIKLUS ÉS STÁTUSZ ---
year_from: Mapped[Optional[int]] = mapped_column(Integer, index=True)
year_to: Mapped[Optional[int]] = mapped_column(Integer, index=True)
production_status: Mapped[Optional[str]] = mapped_column(String(50)) # active / discontinued
# Státusz szintek: unverified, research_in_progress, awaiting_ai_synthesis, gold_enriched
status: Mapped[str] = mapped_column(String(50), server_default=text("'unverified'"), index=True)
is_manual: Mapped[bool] = mapped_column(Boolean, default=False)
source: Mapped[Optional[str]] = mapped_column(String(100))
# --- ADAT-KONTÉNEREK ---
raw_search_context: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
research_metadata: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
specifications: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # Robot 2.2/2.5 Arany adatai
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
last_research_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# --- BEÁLLÍTÁSOK ---
__table_args__ = (
UniqueConstraint('make', 'normalized_name', 'variant_code', 'version_code', 'fuel_type', name='uix_vmd_precision'),
Index('idx_vmd_lookup_fast', 'make', 'normalized_name'),
Index('idx_vmd_engine_bridge', 'make', 'engine_code'),
{"schema": "data"}
)
# KAPCSOLATOK
v_type_rel: Mapped["VehicleType"] = relationship("VehicleType", back_populates="definitions")
feature_maps: Mapped[List["ModelFeatureMap"]] = relationship("ModelFeatureMap", back_populates="model_definition")
# Hivatkozás az asset.py-ban lévő osztályra
# Megjegyzés: Ha az AssetCatalog nincs itt importálva, húzzal adjuk meg a nevet
variants: Mapped[List["AssetCatalog"]] = relationship("AssetCatalog", back_populates="master_definition")
class ModelFeatureMap(Base):
""" Kapcsolótábla a modellek és az alapfelszereltség között """
__tablename__ = "model_feature_maps"
__table_args__ = {"schema": "data"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
model_definition_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.vehicle_model_definitions.id"))
feature_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.feature_definitions.id"))
is_standard: Mapped[bool] = mapped_column(Boolean, default=True)
model_definition: Mapped["VehicleModelDefinition"] = relationship("VehicleModelDefinition", back_populates="feature_maps")
feature: Mapped["FeatureDefinition"] = relationship("FeatureDefinition", back_populates="model_maps")

123
backend/app/schemas/admin.py Executable file
View File

@@ -0,0 +1,123 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, delete
from typing import List, Any, Dict, Optional
from datetime import datetime, timedelta
from app.api import deps
from app.models.identity import User, UserRole
from app.models.system import SystemParameter
from app.models.audit import SecurityAuditLog, OperationalLog
from app.models.security import PendingAction, ActionStatus
from app.services.security_service import security_service
from app.services.translation_service import TranslationService
from app.schemas.admin import PointRuleResponse, LevelConfigResponse, ConfigUpdate
from app.schemas.admin_security import PendingActionResponse, SecurityStatusResponse
router = APIRouter()
# --- 🛡️ ADMIN JOGOSULTSÁG ELLENŐRZŐ ---
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
""" Csak Admin vagy Superadmin léphet be a Sentinel központba. """
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sentinel jogosultság szükséges a művelethez!"
)
return current_user
# --- 🛰️ 1. SENTINEL: RENDSZERÁLLAPOT ÉS MONITORING ---
@router.get("/health-monitor", response_model=Dict[str, Any], tags=["Sentinel Monitoring"])
async def get_system_health(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
""" Részletes rendszerstatisztikák (Felhasználók, Eszközök, Biztonság). """
stats = {}
# Felhasználói eloszlás (Nyers SQL a sebességért)
user_res = await db.execute(text("SELECT subscription_plan, count(*) FROM data.users GROUP BY subscription_plan"))
stats["user_distribution"] = {row[0]: row[1] for row in user_res}
# Eszköz és Szervezet számlálók
stats["total_assets"] = (await db.execute(text("SELECT count(*) FROM data.assets"))).scalar()
stats["total_organizations"] = (await db.execute(text("SELECT count(*) FROM data.organizations"))).scalar()
# Biztonsági riasztások (Kritikus logok az elmúlt 24 órában)
day_ago = datetime.now() - timedelta(days=1)
crit_logs = await db.execute(
select(func.count(SecurityAuditLog.id))
.where(SecurityAuditLog.is_critical == True, SecurityAuditLog.created_at >= day_ago)
)
stats["critical_alerts_24h"] = crit_logs.scalar() or 0
return stats
# --- ⚖️ 2. SENTINEL: NÉGY SZEM ELV (Approval System) ---
@router.get("/pending-actions", response_model=List[PendingActionResponse], tags=["Sentinel Security"])
async def list_pending_actions(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
""" Jóváhagyásra váró kritikus műveletek listázása. """
stmt = select(PendingAction).where(PendingAction.status == ActionStatus.pending)
result = await db.execute(stmt)
return result.scalars().all()
@router.post("/approve/{action_id}", tags=["Sentinel Security"])
async def approve_action(
action_id: int,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
""" Művelet véglegesítése egy második admin által. """
try:
await security_service.approve_action(db, admin.id, action_id)
return {"status": "success", "message": "Művelet végrehajtva."}
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# --- ⚙️ 3. DINAMIKUS KONFIGURÁCIÓ (System Parameters) ---
@router.get("/parameters", tags=["Dynamic Configuration"])
async def list_all_parameters(
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
""" Globális és lokális paraméterek (Limitek, XP szorzók) lekérése. """
result = await db.execute(select(SystemParameter))
return result.scalars().all()
@router.post("/parameters", tags=["Dynamic Configuration"])
async def set_parameter(
config: ConfigUpdate,
db: AsyncSession = Depends(deps.get_db),
admin: User = Depends(check_admin_access)
):
""" Paraméter beállítása vagy frissítése hierarchikus scope-al. """
query = text("""
INSERT INTO data.system_parameters (key, value, scope_level, scope_id, category, last_modified_by)
VALUES (:key, :val, :sl, :sid, :cat, :user)
ON CONFLICT (key, scope_level, scope_id)
DO UPDATE SET
value = EXCLUDED.value,
category = EXCLUDED.category,
last_modified_by = EXCLUDED.last_modified_by,
updated_at = now()
""")
await db.execute(query, {
"key": config.key, "val": config.value, "sl": config.scope_level,
"sid": config.scope_id, "cat": config.category, "user": admin.email
})
await db.commit()
return {"status": "success", "message": f"'{config.key}' frissítve."}
@router.post("/translations/sync", tags=["System Utilities"])
async def sync_translations(db: AsyncSession = Depends(deps.get_db), admin: User = Depends(check_admin_access)):
""" DB fordítások exportálása JSON fájlokba a frontendnek. """
await TranslationService.export_to_json(db)
return {"message": "Nyelvi fájlok frissítve."}

View File

@@ -0,0 +1,23 @@
# /opt/docker/dev/service_finder/backend/app/schemas/admin_security.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional, Any, Dict
from app.models.security import ActionStatus
class PendingActionResponse(BaseModel):
id: int
requester_id: int
action_type: str
payload: Dict[str, Any]
reason: str
status: ActionStatus
created_at: datetime
expires_at: datetime
model_config = ConfigDict(from_attributes=True)
class SecurityStatusResponse(BaseModel):
total_pending: int
critical_logs_last_24h: int
emergency_locks_active: int

56
backend/app/schemas/asset.py Executable file
View File

@@ -0,0 +1,56 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset.py
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, Dict, Any, List
from uuid import UUID
from datetime import datetime
class AssetCatalogResponse(BaseModel):
""" A technikai katalógus (Master Data) teljes adattartalma. """
id: int
make: str
model: str
generation: Optional[str] = None
engine_variant: Optional[str] = None
year_from: Optional[int] = None
year_to: Optional[int] = None
vehicle_class: Optional[str] = None
fuel_type: Optional[str] = None
# Technikai paraméterek az automatizáláshoz
power_kw: Optional[int] = None
engine_capacity: Optional[int] = None
max_weight_kg: Optional[int] = None
axle_count: Optional[int] = None
euro_class: Optional[str] = None
body_type: Optional[str] = None
engine_code: Optional[str] = None
factory_data: Dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True)
class AssetResponse(BaseModel):
""" A konkrét járműpéldány (Asset) teljes válaszmodellje. """
id: UUID
vin: str = Field(..., min_length=17, max_length=17)
license_plate: Optional[str] = None
name: Optional[str] = None
year_of_manufacture: Optional[int] = None
# Státusz és ellenőrzés
status: str
is_verified: bool
verification_method: Optional[str] = None
catalog_match_score: Optional[float] = None
# Kapcsolt adatok
catalog_id: Optional[int] = None
catalog: Optional[AssetCatalogResponse] = None # Itt jön a dúsítás!
owner_organization_id: Optional[int] = None
operator_person_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,35 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, Dict, Any
from datetime import datetime
from decimal import Decimal
from uuid import UUID
class AssetCostBase(BaseModel):
cost_type: str # fuel, service, tax, insurance
amount_local: Decimal
currency_local: str = "HUF"
net_amount_local: Optional[Decimal] = None
vat_rate: Optional[Decimal] = Field(default=27.0)
date: datetime = Field(default_factory=datetime.now)
mileage_at_cost: Optional[int] = None
description: Optional[str] = None
data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták
class AssetCostCreate(AssetCostBase):
asset_id: UUID
organization_id: int
class AssetCostResponse(AssetCostBase):
id: UUID
asset_id: UUID
organization_id: int
driver_id: Optional[int] = None
# Pénzügyi dúsítás (Backend számolja)
amount_eur: Optional[Decimal] = None
exchange_rate_used: Optional[Decimal] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)

54
backend/app/schemas/auth.py Executable file
View File

@@ -0,0 +1,54 @@
# /opt/docker/dev/service_finder/backend/app/schemas/auth.py
from pydantic import BaseModel, EmailStr, Field, ConfigDict
from typing import Optional, Dict, List
from datetime import date, datetime
class DocumentDetail(BaseModel):
number: str
expiry_date: date
class ICEContact(BaseModel):
name: str
phone: str
relationship: str
class UserLiteRegister(BaseModel):
""" Step 1: Gyors regisztráció (Alap azonosítás). """
email: EmailStr
password: str = Field(..., min_length=8, description="Minimum 8 karakter hosszú jelszó")
first_name: str
last_name: str
model_config = ConfigDict(from_attributes=True)
class UserKYCComplete(BaseModel):
""" Step 2: Teljes körű személyazonosítás és címadatok. """
phone_number: str = Field(..., pattern=r"^\+?[0-9]{7,15}$")
birth_place: str
birth_date: date
mothers_last_name: str
mothers_first_name: str
# Atomizált címadatok a pontos GPS-hez és Robot-munkához
address_zip: str
address_city: str
address_street_name: str
address_street_type: str # utca, út, tér...
address_house_number: str
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None # Külterület/Helyrajzi szám
# Okmányok és Vészhelyzet
identity_docs: Dict[str, DocumentDetail] # pl: {"ID_CARD": {...}, "LICENSE": {...}}
ice_contact: ICEContact
preferred_language: str = "hu"
preferred_currency: str = "HUF"
class Token(BaseModel):
access_token: str
refresh_token: Optional[str] = None
token_type: str = "bearer"
is_active: bool

47
backend/app/schemas/evidence.py Executable file
View File

@@ -0,0 +1,47 @@
# app/schemas/evidence.py
from pydantic import BaseModel, Field
from typing import Optional
class RegistrationDocumentExtracted(BaseModel):
"""A magyar forgalmi engedély teljes adattartalma."""
# A - Okmány adatok
license_plate: Optional[str] = Field(None, alias="A", description="Rendszám")
first_registration_date: Optional[str] = Field(None, alias="B", description="Első nyilvántartásba vétel")
doc_serial_number: Optional[str] = Field(None, description="Okmány sorszáma (jobb felső sarok)")
# C - Tulajdonos/Üzembentartó adatok
owner_last_name: Optional[str] = Field(None, alias="C.1.1", description="Családi név vagy cégnév")
owner_first_name: Optional[str] = Field(None, alias="C.1.2", description="Utónév")
owner_address: Optional[str] = Field(None, alias="C.1.3", description="Lakcím/Székhely")
owner_status: Optional[str] = Field(None, alias="C.4", description="Jogosultság státusza (a=tulaj, b=nem tulaj)")
# D - Jármű technikai adatai
make: Optional[str] = Field(None, alias="D.1", description="Gyártmány")
vehicle_type: Optional[str] = Field(None, alias="D.2", description="Típus")
commercial_description: Optional[str] = Field(None, alias="D.3", description="Kereskedelmi leírás")
vin: Optional[str] = Field(None, alias="E", description="Alvázszám (17 karakter)")
# G, F - Tömeg adatok
weight_kg: Optional[int] = Field(None, alias="G", description="Saját tömeg")
max_weight_kg: Optional[int] = Field(None, alias="F.1", description="Együttes tömeg")
# P, V - Motor és Környezetvédelem
engine_capacity: Optional[int] = Field(None, alias="P.1", description="Hengerűrtartalom (cm3)")
engine_power: Optional[float] = Field(None, alias="P.2", description="Teljesítmény (kW)")
fuel_type: Optional[str] = Field(None, alias="P.3", description="Hajtóanyag")
engine_code: Optional[str] = Field(None, alias="P.5", description="Motorkód")
env_category: Optional[str] = Field(None, alias="V.9", description="Környezetvédelmi osztály")
# R, S, H - Egyéb
color: Optional[str] = Field(None, alias="R", description="Szín")
seats: Optional[int] = Field(None, alias="S.1", description="Ülések száma")
expiry_date: Optional[str] = Field(None, alias="H", description="Műszaki érvényesség")
transmission_type: Optional[str] = Field(None, description="Sebességváltó fajtája")
class Config:
populate_by_name = True
class OcrResponse(BaseModel):
success: bool
message: str
data: Optional[RegistrationDocumentExtracted] = None

20
backend/app/schemas/fleet.py Executable file
View File

@@ -0,0 +1,20 @@
# /opt/docker/dev/service_finder/backend/app/schemas/fleet.py
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import date
from uuid import UUID
class EventCreate(BaseModel):
asset_id: UUID
event_type: str # 'SERVICE', 'FUEL', 'MOT'
date: date
odometer_value: int
cost_amount: float
description: Optional[str] = None
provider_id: Optional[int] = None
class TCOStats(BaseModel):
asset_id: UUID
total_cost_huf: float
cost_per_km: float
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,38 @@
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
class ContactCreate(BaseModel):
full_name: str
email: str
phone: Optional[str] = None
contact_type: str = "primary"
class CorpOnboardIn(BaseModel):
""" Teljes onboarding adatcsomag atomizált címekkel. """
full_name: str = Field(..., description="Hivatalos cégnév")
name: str = Field(..., description="Rövid név")
display_name: str
tax_number: str
reg_number: Optional[str] = None
country_code: str = "HU"
language: str = "hu"
default_currency: str = "HUF"
# --- ATOMIZÁLT CÍM (Modell szinkron) ---
address_zip: str
address_city: str
address_street_name: str
address_street_type: str
address_house_number: str
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
contacts: List[ContactCreate] = []
class CorpOnboardResponse(BaseModel):
organization_id: int
status: str
model_config = ConfigDict(from_attributes=True)

38
backend/app/schemas/service.py Executable file
View File

@@ -0,0 +1,38 @@
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
class ContactCreate(BaseModel):
full_name: str
email: str
phone: Optional[str] = None
contact_type: str = "primary"
class CorpOnboardIn(BaseModel):
""" Teljes onboarding adatcsomag atomizált címekkel. """
full_name: str = Field(..., description="Hivatalos cégnév")
name: str = Field(..., description="Rövid név")
display_name: str
tax_number: str
reg_number: Optional[str] = None
country_code: str = "HU"
language: str = "hu"
default_currency: str = "HUF"
# --- ATOMIZÁLT CÍM (Modell szinkron) ---
address_zip: str
address_city: str
address_street_name: str
address_street_type: str
address_house_number: str
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
contacts: List[ContactCreate] = []
class CorpOnboardResponse(BaseModel):
organization_id: int
status: str
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,9 @@
# /opt/docker/dev/service_finder/backend/app/schemas/service_hunt.py
class ServiceHuntRequest(BaseModel):
name: str
category_id: int
address: str
latitude: float
longitude: float
user_latitude: float
user_longitude: float

58
backend/app/schemas/social.py Executable file
View File

@@ -0,0 +1,58 @@
# /opt/docker/dev/service_finder/backend/app/schemas/social.py
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import datetime
from app.models.social import ModerationStatus, SourceType
# --- Alap Sémák (Szolgáltatók) ---
class ServiceProviderBase(BaseModel):
name: str
address: Optional[str] = None
category: Optional[str] = None
source: SourceType = SourceType.manual
class ServiceProviderCreate(BaseModel):
name: str
address: str
category: Optional[str] = None
class ServiceProviderResponse(ServiceProviderBase):
id: int
status: ModerationStatus
validation_score: int
evidence_image_path: Optional[str] = None
added_by_user_id: Optional[int] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# --- Gamifikáció és Szavazás (Voting & Gamification) ---
class VoteCreate(BaseModel):
vote_value: int
class LeaderboardEntry(BaseModel):
username: str
points: int
rank: int
model_config = ConfigDict(from_attributes=True)
class BadgeSchema(BaseModel):
id: int
name: str
description: str
icon_url: Optional[str] = None # JAVÍTVA: icon_url a modell szerint
model_config = ConfigDict(from_attributes=True) # Pydantic V2 kompatibilis
class UserStatSchema(BaseModel):
user_id: int
total_xp: int # JAVÍTVA: total_xp a modell szerint
current_level: int
penalty_points: int # JAVÍTVA: új mező
rank_title: Optional[str] = None
badges: List[BadgeSchema] = []
model_config = ConfigDict(from_attributes=True)

10
backend/app/schemas/token.py Executable file
View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None
role: Optional[str] = None

25
backend/app/schemas/user.py Executable file
View File

@@ -0,0 +1,25 @@
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional
from datetime import date
class UserBase(BaseModel):
email: EmailStr
first_name: Optional[str] = None
last_name: Optional[str] = None
is_active: bool = True
region_code: str = "HU"
class UserResponse(UserBase):
id: int
person_id: Optional[int] = None
role: str
subscription_plan: str
scope_level: str
scope_id: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class UserUpdate(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
preferred_language: Optional[str] = None

View File

@@ -0,0 +1,30 @@
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Any
from uuid import UUID
from datetime import datetime
class EngineSpecBase(BaseModel):
engine_code: str
fuel_type: str
power_kw: int
default_service_interval_km: int = 15000
class VehicleBase(BaseModel):
brand_id: int
model_name: str
identification_number: str
license_plate: Optional[str] = None
tracking_mode: str = "km"
class VehicleCreate(VehicleBase):
current_company_id: int
engine_spec_id: int
class VehicleRead(VehicleBase):
id: UUID
current_rating_pct: int
total_real_usage: float
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,20 @@
# app/core/schemas/vehicle_categories.py
VEHICLE_SCHEMAS = {
"motorcycle": {
"features": ["ABS", "Markolatfűtés", "Szélvédő", "Bukócső/gomba", "Automata váltó", "Gyári dobozok", "Zárható doboz", "Veterán"],
"service_items": ["motorolaj", "olajszűrő", "levegőszűrő", "lánc_szett", "fékfolyadék", "gyújtógyertya", "szelephézag_ellenőrzés"]
},
"car": {
"features": ["Automata", "Tempomat", "Összkerékhajtás", "Alufelni", "Elektromos ablak", "Vonóhorog", "ISOFIX rendszer", "ESP", "Szervizkönyv", "Veterán"],
"service_items": ["motorolaj", "olajszűrő", "levegőszűrő", "pollenszűrő", "vezérlés_szett", "hosszbordásszíj", "váltóolaj", "fagyálló"]
},
"truck": {
"features": ["Légrugó", "Hálófülke", "Retarder/Intarder", "Emelőhátfal", "Tengelysúly-mérő", "AdBlue", "Állóhelyzeti klíma"],
"service_items": ["motorolaj", "légfék_szárító_patron", "üzemanyagszűrő", "érintésvédelmi_vizsga", "tengely_zsírozás"]
},
"boat": {
"features": ["Utánfutó", "Takaróponyva", "Orrsugárkormány", "Halradar", "Kormányállás", "Üzemanyagtartály", "Sólyakocsi", "Zárható tároló", "Elektromos horgonycsörlő"],
"service_items": ["motorolaj", "hajómotor_anód", "vízpumpa_lapát", "téliesítés", "algagátlózás"]
}
}

View File

@@ -0,0 +1,38 @@
import asyncio
import httpx
from sqlalchemy import text
from app.db.session import engine
from datetime import datetime
async def log_discovery(conn, category, brand, model, action):
await conn.execute(text("""
INSERT INTO data.bot_discovery_logs (category, brand_name, model_name, action_taken)
VALUES (:c, :b, :m, :a)
"""), {"c": category, "b": brand, "m": model, "a": action})
async def run_discovery():
async with engine.begin() as conn:
print(f"🚀 Jármű felfedezés indul: {datetime.now()}")
# Jelenleg a CAR kategóriára fókuszálunk egy külső API segítségével (pl. NHTSA - Ingyenes)
# Itt egy példa, hogyan bővül dinamikusan a rendszer
async with httpx.AsyncClient() as client:
# Autók lekérése
response = await client.get("https://vpic.nhtsa.dot.gov/api/vehicles/getallmakes?format=json")
if response.status_code == 200:
makes = response.json().get('Results', [])[:100] # Tesztként az első 100
for make in makes:
brand_name = make['Make_Name'].strip()
# Megnézzük, megvan-e már
res = await conn.execute(text("SELECT id FROM data.vehicle_brands WHERE name = :n"), {"n": brand_name})
if not res.scalar():
await conn.execute(text("INSERT INTO data.vehicle_brands (category_id, name) VALUES (1, :n)"), {"n": brand_name})
await log_discovery(conn, "CAR", brand_name, "ALL", "NEW_BRAND")
print(f"✨ Új márka találva: {brand_name}")
await conn.commit()
print("✅ Bot futása befejeződött.")
if __name__ == "__main__":
asyncio.run(run_discovery())

View File

@@ -0,0 +1,63 @@
# /opt/docker/dev/service_finder/backend/app/scripts/link_catalog_to_mdm.py
import asyncio
from sqlalchemy import select, update
from app.db.session import SessionLocal
from app.models.asset import AssetCatalog
from app.models.vehicle_definitions import VehicleModelDefinition, VehicleType
async def link_catalog_to_mdm():
""" Összefűzi a technikai katalógust a központi Master Definíciókkal. """
async with SessionLocal() as db:
try:
print("🔍 Master-Híd építése indul...")
# 1. Típusok betöltése
type_res = await db.execute(select(VehicleType))
types = {t.code: t.id for t in type_res.scalars().all()}
# 2. Egyedi variánsok lekérése
stmt = select(AssetCatalog.make, AssetCatalog.model, AssetCatalog.vehicle_class).distinct()
raw_data = await db.execute(stmt)
unique_models = raw_data.all()
linked_count = 0
for make, model, v_class in unique_models:
t_code = v_class if v_class in types else "car"
t_id = types.get(t_code)
# Master rekord keresése vagy létrehozása
master_stmt = select(VehicleModelDefinition).where(
VehicleModelDefinition.make == make,
VehicleModelDefinition.marketing_name == model
)
master = (await db.execute(master_stmt)).scalar_one_or_none()
if not master:
master = VehicleModelDefinition(
make=make,
technical_code=model.replace(" ", "-").lower(),
marketing_name=model,
vehicle_type=t_code,
vehicle_type_id=t_id,
status="unverified",
source="linking_process"
)
db.add(master)
await db.flush()
# Összekötés
await db.execute(
update(AssetCatalog)
.where(AssetCatalog.make == make, AssetCatalog.model == model)
.values(master_definition_id=master.id)
)
linked_count += 1
await db.commit()
print(f"✅ Sikeresen összekötve: {linked_count} modell.")
except Exception as e:
await db.rollback()
print(f"❌ Hiba: {e}")
if __name__ == "__main__":
asyncio.run(link_catalog_to_mdm())

View File

@@ -0,0 +1,35 @@
# /opt/docker/dev/service_finder/backend/app/scripts/morning_report.py
import asyncio
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.audit import ProcessLog
from datetime import datetime, timedelta, timezone
async def generate_morning_report():
""" Összesíti a háttérfolyamatok (robotok) elmúlt 24 órás teljesítményét. """
async with SessionLocal() as db:
yesterday = datetime.now(timezone.utc) - timedelta(days=1)
stmt = select(ProcessLog).where(ProcessLog.start_time >= yesterday)
res = await db.execute(stmt)
logs = res.scalars().all()
report = f"📊 REGGELI ROBOT JELENTÉS - {datetime.now().date()}\n"
report += "="*40 + "\n"
total_proc = sum(log.items_processed for log in logs)
total_fail = sum(log.items_failed for log in logs)
report += f"✅ Feldolgozott egységek: {total_proc}\n"
report += f"❌ Sikertelen műveletek: {total_fail}\n"
if logs:
report += "\nAktív robotok állapota:\n"
for log in logs:
status = "🟢 OK" if log.items_failed == 0 else "🔴 HIBA"
report += f" - {log.process_name}: {log.items_processed} feldolgozva ({status})\n"
print(report)
return report
if __name__ == "__main__":
asyncio.run(generate_morning_report())

View File

@@ -0,0 +1,428 @@
# /opt/docker/dev/service_finder/backend/app/scripts/seed_system_params.py
import asyncio
import logging
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.system import SystemParameter
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
logger = logging.getLogger("Seed-System-Params-2.0")
async def seed_params():
async with AsyncSessionLocal() as db:
# ----------------------------------------------------------------------
# GONDOLATMENET A STRUKTÚRÁHOZ:
# Ez a lista tartalmazza a rendszer összes "alapértelmezett" (global) beállítását.
# Minden modul innen kapja meg az indulási értékeit. Ha az admin felületen
# egy értéket módosítanak, ez a script a következő futáskor NEM írja felül azt,
# csak a leírásokat (description) és a kategóriákat frissíti.
# ----------------------------------------------------------------------
params = [
# --- 1. KOMMUNIKÁCIÓ (Az EmailManager 2.0 motorja) ---
{
"key": "email_provider",
"value": "smtp", # Lehetőségek: "smtp", "sendgrid", "disabled"
"category": "communication",
"description": "Aktív e-mail küldő szolgáltató",
"scope_level": "global"
},
{
"key": "emails_from_email",
"value": "noreply@profibot.hu",
"category": "communication",
"description": "A rendszer által küldött levelek feladó címe",
"scope_level": "global"
},
{
"key": "emails_from_name",
"value": "Sentinel Master",
"category": "communication",
"description": "A rendszer által küldött levelek feladó neve",
"scope_level": "global"
},
{
"key": "sendgrid_api_key",
"value": "",
"category": "communication",
"description": "SendGrid API kulcs (ha a provider 'sendgrid')",
"scope_level": "global"
},
{
"key": "smtp_config",
"value": {
"host": "localhost",
"port": 587,
"user": "smtp_user",
"pass": "smtp_password",
"tls": True
},
"category": "communication",
"description": "SMTP szerver konfiguráció JSON formátumban",
"scope_level": "global"
},
# --- 2. AUTH & SECURITY (Kapuőr modul) ---
{"key": "auth_min_password_length", "value": 8, "category": "security", "description": "Minimum jelszóhossz", "scope_level": "global"},
{"key": "auth_default_role", "value": "user", "category": "auth", "description": "Új regisztrálók alapértelmezett rangja", "scope_level": "global"},
{"key": "auth_registration_hours", "value": 48, "category": "auth", "description": "Regisztrációs link érvényessége", "scope_level": "global"},
{"key": "auth_password_reset_hours", "value": 2, "category": "security", "description": "Jelszóvisszaállító link érvényessége", "scope_level": "global"},
{"key": "asset_auto_transfer_enabled", "value": False, "category": "security", "description": "Autonóm tulajdonosváltás engedélyezése", "scope_level": "global"},
# --- 3. LIMITS & CSOMAGOK (A Billing és Asset korlátok) ---
{
"key": "VEHICLE_LIMIT",
"value": {"free": 1, "premium": 5, "vip": 50, "service_pro": 10},
"category": "limits",
"description": "Járműszám korlátok előfizetés szerint",
"scope_level": "global"
},
{
"key": "subscription_packages_matrix",
"value": {
"free": {"price": 0, "rank": 1, "type": "credit"},
"premium": {"price": 1990, "rank": 5, "type": "credit"},
"vip": {"price": 4990, "rank": 50, "type": "credit"},
"service_pro": {"price": 9990, "rank": 30, "type": "coin", "initial_coin_bonus": 500}
},
"category": "billing",
"description": "Csomagok, árak és bónuszok központi mátrixa",
"scope_level": "global"
},
# --- 4. FINANCE (Költségek és Devizák) ---
{"key": "finance_default_currency", "value": "HUF", "category": "finance", "description": "Helyi alap deviza", "scope_level": "global"},
{"key": "finance_base_currency", "value": "EUR", "category": "finance", "description": "Központi elszámoló deviza (Statisztikákhoz)", "scope_level": "global"},
{"key": "org_naming_template", "value": "{last_name} Flotta", "category": "system", "description": "Szervezet név sablon", "scope_level": "global"},
# --- 5. DOCUMENT & OCR (Robot 1 vezérlése) ---
{
"key": "ocr_monthly_limit",
"value": {"free": 1, "premium": 10, "vip": 100},
"category": "limits",
"description": "Havi ingyenes OCR szkennelések száma",
"scope_level": "global"
},
{
"key": "ocr_auto_trigger_types",
"value": ["invoice", "registration_card", "sale_contract"],
"category": "robots",
"description": "Azonnali OCR feldolgozásra kijelölt típusok",
"scope_level": "global"
},
# --- 6. GAMIFICATION (A Játékmester) ---
{"key": "gamification_kyc_bonus", "value": 500, "category": "gamification", "description": "XP jutalom a KYC után", "scope_level": "global"},
{"key": "xp_multiplier_ocr_cost", "value": 1.5, "category": "gamification", "description": "Bónusz szorzó digitális (OCR) adatrögzítésre", "scope_level": "global"},
{
"key": "GAMIFICATION_MASTER_CONFIG",
"value": {
"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}
},
"category": "gamification",
"description": "Szintek, büntetések és jutalmak mátrixa",
"scope_level": "global"
},
# --- 7. ÉRTESÍTÉSEK ÉS KARBANTARTÁS ---
{
"key": "notif_expiry_threshold_days",
"value": 30,
"category": "notifications",
"description": "Hány nappal az okmányok lejárata előtt küldjön a rendszer automata figyelmeztetést?",
"scope_level": "global"
},
{
"key": "notif_expiry_steps",
"value": [30, 7, 1, 0],
"category": "notifications",
"description": "Hány nappal a lejárat előtt küldjön a rendszer riasztást? (Lista formátum)",
"scope_level": "global"
},
{
"key": "maint_km_alert_threshold",
"value": 1000,
"category": "maintenance",
"description": "Hány kilométerrel a tervezett szerviz előtt küldjön figyelmeztetést a rendszer?",
"scope_level": "global"
},
{
"key": "storage_retention_days",
"value": 365,
"category": "storage",
"description": "Fájlok megőrzési ideje a NAS-on (napokban)",
"scope_level": "global"
},
{
"key": "storage_delete_after_validation",
"value": False,
"category": "storage",
"description": "Törölje-e a rendszer az OCR bizonyítékokat a sikeres hitelesítés után?",
"scope_level": "global"
},
# --- 8. GEO & LOCALIZATION ---
{
"key": "geo_default_country_code",
"value": "HU",
"category": "geo",
"description": "Alapértelmezett országkód a címek normalizálásához",
"scope_level": "global"
},
{
"key": "GEO_ADDRESS_FORMAT_TEMPLATE",
"value": "{zip} {city}, {street} {type} {number}.",
"category": "geo",
"description": "Címformázási sablon (Python string format)",
"scope_level": "global"
},
{
"key": "GEO_SUGGESTION_LIMIT",
"value": 15,
"category": "geo",
"description": "Hány találatot adjon vissza az utca-kiegészítő?",
"scope_level": "global"
},
# --- 9. ÉRTESÍTÉSI MÁTRIXOK (A granuláris szabályozáshoz) ---
{
"key": "notification_categories_config",
"value": {
"insurance": {"mandatory": True, "channels": ["email", "internal"], "label": "Biztosítás lejárat"},
"mot_expiry": {"mandatory": True, "channels": ["email", "internal"], "label": "Műszaki vizsga"},
"personal_id": {"mandatory": True, "channels": ["email", "internal"], "label": "Személyi okmányok"},
"service_due": {"mandatory": False, "channels": ["internal"], "label": "Karbantartási emlékeztető"},
"system_alert": {"mandatory": True, "channels": ["email", "internal"], "label": "Rendszerüzenetek"}
},
"category": "notifications",
"description": "Értesítési típusok és biztonsági szintek mátrixa",
"scope_level": "global"
},
{
"key": "NOTIFICATION_TYPE_MATRIX",
"value": {
"insurance": [45, 30, 15, 7, 1, 0], # A kötelező biztosítás kiemelt kezelése!
"mot": [30, 14, 7, 1, 0],
"personal_id": [60, 30, 15, 0],
"default": [30, 7, 1]
},
"category": "notifications",
"description": "Dokumentum alapú riasztási naptár mátrix",
"scope_level": "global"
},
# --- 10. FLEET & TELEMETRY ---
{
"key": "FLEET_EVENT_REWARDS",
"value": {
"refuel": {"xp": 30, "social": 5}, # Tankolás rögzítése
"service": {"xp": 150, "social": 30}, # Szerviz látogatás (értékesebb adat!)
"repair": {"xp": 100, "social": 20}, # Javítás
"tire_change": {"xp": 40, "social": 5}, # Gumicsere
"accident": {"xp": 10, "social": 0}, # Baleset
"default": {"xp": 20, "social": 2}
},
"category": "fleet",
"description": "Eseményenkénti gamifikációs jutalom mátrix",
"scope_level": "global"
},
{
"key": "FLEET_ANOMALY_LOGIC",
"value": {
"odometer_drop_severity": "critical",
"excessive_daily_km": 1500,
"flag_unverified_providers": True
},
"category": "fleet",
"description": "Flotta anomália detekciós küszöbértékek",
"scope_level": "global"
},
# --- 11. KÜLSŐ API-K (DVLA, UK) ---
{
"key": "dvla_api_enabled",
"value": True,
"category": "api_keys",
"description": "Engedélyezze-e a brit DVLA lekérdezéseket?",
"scope_level": "global"
},
{
"key": "dvla_api_url",
"value": "https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles",
"category": "api_keys",
"description": "Hivatalos DVLA Vehicle Enquiry API végpont",
"scope_level": "global"
},
{
"key": "dvla_api_key",
"value": "IDE_JÖN_A_VALÓDI_KULCS",
"category": "api_keys",
"description": "Bizalmas DVLA API kulcs (X-API-KEY)",
"scope_level": "global"
},
# --- 12. AI & ROBOTOK (Ollama integráció) ---
{
"key": "ai_model_text",
"value": "qwen2.5-coder:32b",
"category": "ai",
"description": "Fő technikai elemző modell (Ollama)",
"scope_level": "global"
},
{
"key": "ai_model_vision",
"value": "llava:7b",
"category": "ai",
"description": "Látó modell az OCR folyamatokhoz",
"scope_level": "global"
},
{
"key": "ai_temperature",
"value": 0.1,
"category": "ai",
"description": "AI válasz kreativitása (0.1 = precíz, 0.9 = kreatív)",
"scope_level": "global"
},
{
"key": "ai_prompt_ocr_invoice",
"value": "FELADAT: Olvasd ki a számla adatait. JSON válasz: {amount, currency, date, vendor, vat}.",
"category": "ai",
"description": "Robot 1 - Számla OCR prompt",
"scope_level": "global"
},
{
"key": "ai_prompt_gold_data",
"value": "Készíts technikai adatlapot a(z) {make} {model} típushoz a megadott adatok alapján: {context}. Csak hiteles JSON-t adj!",
"category": "ai",
"description": "Robot 3 - Technikai dúsító prompt",
"scope_level": "global"
}
] # <-- ITT HIÁNYZOTT A ZÁRÓJEL!
# ----------------------------------------------------------------------
# HIERARCHIKUS KERESÉSI MÁTRIXOK (A SearchService 2.4-hez)
# Ezek az értékek felülbírálják az alapértelmezéseket a megfelelő "scope" esetén.
# ----------------------------------------------------------------------
# 1. GLOBÁLIS ALAP (Free usereknek)
params.append({
"key": "RANKING_RULES",
"scope_level": "global",
"scope_id": None,
"value": {
"ad_weight": 8000,
"partner_weight": 1000,
"trust_weight": 5,
"dist_penalty": 40,
"can_use_prefs": False,
"search_radius_km": 25
},
"category": "search",
"description": "Alapértelmezett (Free) rangsorolási szabályok"
})
# 2. PREMIUM CSOMAG SZINTŰ BEÁLLÍTÁS (Közepes szint)
params.append({
"key": "RANKING_RULES",
"scope_level": "package",
"scope_id": "premium",
"value": {
"pref_weight": 10000,
"partner_weight": 2000,
"trust_weight": 50,
"ad_weight": 500,
"dist_penalty": 20,
"can_use_prefs": True,
"search_radius_km": 50
},
"category": "search",
"description": "Prémium csomag rangsorolási szabályai"
})
# 3. VIP CSOMAG SZINTŰ BEÁLLÍTÁS
params.append({
"key": "RANKING_RULES",
"scope_level": "package",
"scope_id": "vip",
"value": {
"pref_weight": 20000, # A kedvenc mindent visz
"partner_weight": 5000,
"trust_weight": 100, # A minőség számít
"ad_weight": 0, # VIP-nek nem tolunk hirdetést az élre
"dist_penalty": 5, # Alig büntetjük a távolságot
"can_use_prefs": True,
"search_radius_km": 150
},
"category": "search",
"description": "VIP csomag rangsorolási szabályai"
})
# 4. EGYÉNI CÉGES FELÜLBÍRÁLÁS (Pl. ProfiBot Flotta Co.)
params.append({
"key": "RANKING_RULES",
"scope_level": "user",
"scope_id": "99",
"value": {
"pref_weight": 50000, # Nekik csak a saját szerződött partnereik kellenek
"can_use_prefs": True,
"search_radius_km": 500 # Az egész országot látják
},
"category": "search",
"description": "Egyedi flotta-ügyfél keresési szabályai"
})
logger.info("🚀 Rendszerparaméterek szinkronizálása a 2.0-ás modell szerint...")
added_count = 0
updated_count = 0
for p in params:
# GONDOLATMENET A JAVÍTÁSHOZ:
# Muszáj a scope_level-t és scope_id-t is vizsgálni, különben az SQLAlchemy
# összeomlik (MultipleResultsFound), mert ugyanaz a 'key' (pl. RANKING_RULES)
# több sorban is szerepel a hierarchia miatt!
s_level = p.get("scope_level", "global")
s_id = p.get("scope_id", None)
stmt = select(SystemParameter).where(
SystemParameter.key == p["key"],
SystemParameter.scope_level == s_level,
SystemParameter.scope_id == s_id
)
res = await db.execute(stmt)
existing = res.scalar_one_or_none()
if not existing:
# Új rekord létrehozása
new_param = SystemParameter(
key=p["key"],
value=p["value"],
category=p["category"],
description=p["description"],
scope_level=s_level,
scope_id=s_id,
last_modified_by=None
)
db.add(new_param)
added_count += 1
# Azonnali commit, hogy a következő körben már lássa a DB!
await db.commit()
else:
# Csak frissítés, ha szükséges
existing.description = p["description"]
existing.category = p["category"]
updated_count += 1
await db.commit()
logger.info(f"✅ Kész! Új: {added_count}, Frissített meta: {updated_count}")
if __name__ == "__main__":
asyncio.run(seed_params())

View File

@@ -0,0 +1,31 @@
# /opt/docker/dev/service_finder/backend/app/scripts/seed_v1_9_system.py
import asyncio
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.vehicle_definitions import VehicleType, FeatureDefinition
async def seed_system_data():
""" Alapvető típusok és extrák (Features) feltöltése. """
async with SessionLocal() as db:
try:
print("🚀 Rendszer-blueprint betöltése...")
types_data = [
{"code": "car", "name": "Személyautó", "icon": "directions_car"},
{"code": "motorcycle", "name": "Motorkerékpár", "icon": "moped"},
{"code": "truck", "name": "Teherautó", "icon": "local_shipping"},
{"code": "boat", "name": "Hajó", "icon": "sailing"}
]
for t_info in types_data:
stmt = select(VehicleType).where(VehicleType.code == t_info["code"])
if not (await db.execute(stmt)).scalar_one_or_none():
db.add(VehicleType(**t_info))
await db.commit()
print("✅ Blueprint kész.")
except Exception as e:
print(f"❌ Hiba: {e}")
if __name__ == "__main__":
asyncio.run(seed_system_data())

View 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)}")

View 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])

View 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

View 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

View 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

View 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

View 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

View 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()

View 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()

View 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

View 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

View 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()

View 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
)

View 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()

View 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.")

View 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

View 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

View 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()

View 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

View 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

View 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

View 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)

View 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

Some files were not shown because too many files have changed in this diff Show More