szolgáltatók beálltásai, szerkesztése , létrehozása
This commit is contained in:
@@ -5,7 +5,7 @@ from app.api.v1.endpoints import (
|
||||
services, admin, expenses, evidence, social, security,
|
||||
billing, finance_admin, analytics, vehicles, system_parameters,
|
||||
gamification, translations, users, reports, dictionaries,
|
||||
admin_packages, admin_services
|
||||
admin_packages, admin_services, constants, providers
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -32,4 +32,7 @@ api_router.include_router(users.router, prefix="/users", tags=["Users"])
|
||||
api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
|
||||
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
|
||||
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
|
||||
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
|
||||
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
|
||||
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
|
||||
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
|
||||
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
||||
@@ -1,7 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -11,12 +11,12 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models import Asset, AssetCost
|
||||
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember
|
||||
from app.models.identity import User
|
||||
from app.services.cost_service import cost_service
|
||||
from app.services.asset_service import AssetService
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
|
||||
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate
|
||||
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEventCreate, AssetEventResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -174,7 +174,10 @@ async def get_user_vehicles(
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(selectinload(Asset.catalog))
|
||||
.options(
|
||||
selectinload(Asset.catalog),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Corporate mode: only show vehicles belonging to the active organization's garages
|
||||
@@ -212,7 +215,10 @@ async def get_user_vehicles(
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(selectinload(Asset.catalog))
|
||||
.options(
|
||||
selectinload(Asset.catalog),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
@@ -294,9 +300,6 @@ async def get_asset(
|
||||
and vehicle model definition specifications.
|
||||
"""
|
||||
# Check if user has access to this asset
|
||||
from sqlalchemy import or_
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
|
||||
# Get user's organization memberships
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
@@ -317,7 +320,8 @@ async def get_asset(
|
||||
)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition)
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -427,7 +431,7 @@ async def create_or_claim_vehicle(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Vehicle creation error: {e}")
|
||||
logger.error(f"Vehicle creation failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű létrehozásakor")
|
||||
|
||||
|
||||
@@ -508,7 +512,7 @@ async def update_vehicle(
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Vehicle update error: {e}")
|
||||
logger.error(f"Vehicle update error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű frissítésekor")
|
||||
|
||||
|
||||
@@ -588,11 +592,7 @@ async def create_maintenance_record(
|
||||
- currency: string (optional, default: "EUR")
|
||||
- invoice_number: string (optional)
|
||||
"""
|
||||
# Check if user has access to this asset
|
||||
from sqlalchemy import or_
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
|
||||
# Get user's organization memberships
|
||||
# Get user's organization memberships (imports already at module level)
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
@@ -629,7 +629,6 @@ async def create_maintenance_record(
|
||||
|
||||
try:
|
||||
# Parse date
|
||||
from datetime import datetime
|
||||
date = datetime.fromisoformat(payload["date"].replace("Z", "+00:00"))
|
||||
|
||||
# Determine organization ID: use asset's current org, owner org, or user's active organization
|
||||
@@ -642,8 +641,6 @@ async def create_maintenance_record(
|
||||
organization_id = int(current_user.scope_id)
|
||||
except (ValueError, TypeError):
|
||||
# If scope_id is not a valid integer, try to get from organization memberships
|
||||
from sqlalchemy import select
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
).limit(1)
|
||||
@@ -652,8 +649,6 @@ async def create_maintenance_record(
|
||||
organization_id = org_row[0] if org_row else None
|
||||
else:
|
||||
# Try to get from organization memberships
|
||||
from sqlalchemy import select
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
).limit(1)
|
||||
@@ -667,11 +662,11 @@ async def create_maintenance_record(
|
||||
detail="Cannot determine organization for this cost record. Please ensure you have an active organization."
|
||||
)
|
||||
|
||||
# Create AssetCost record
|
||||
# Create AssetCost record with MAINTENANCE category (id=2)
|
||||
maintenance_cost = AssetCost(
|
||||
asset_id=asset_id,
|
||||
organization_id=organization_id,
|
||||
cost_category="maintenance",
|
||||
category_id=2,
|
||||
amount_net=float(payload["cost"]),
|
||||
currency=payload.get("currency", "EUR"),
|
||||
date=date,
|
||||
@@ -684,17 +679,24 @@ async def create_maintenance_record(
|
||||
)
|
||||
|
||||
db.add(maintenance_cost)
|
||||
await db.commit()
|
||||
await db.refresh(maintenance_cost)
|
||||
await db.flush() # Flush to get maintenance_cost.id
|
||||
|
||||
# Also create an AssetEvent for the maintenance
|
||||
from app.models.vehicle import AssetEvent
|
||||
# Also create an AssetEvent for the maintenance (linked via cost_id)
|
||||
maintenance_event = AssetEvent(
|
||||
asset_id=asset_id,
|
||||
event_type="maintenance"
|
||||
user_id=current_user.id,
|
||||
organization_id=organization_id,
|
||||
event_type="MAINTENANCE",
|
||||
odometer_reading=payload.get("odometer"),
|
||||
description=payload.get("description", "Maintenance record"),
|
||||
cost_id=maintenance_cost.id,
|
||||
event_date=date,
|
||||
)
|
||||
db.add(maintenance_event)
|
||||
|
||||
# Single commit for both records
|
||||
await db.commit()
|
||||
await db.refresh(maintenance_cost)
|
||||
|
||||
return maintenance_cost
|
||||
|
||||
@@ -712,6 +714,244 @@ async def create_maintenance_record(
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# DIGITÁLIS SZERVIZKÖNYV (Service Book) — AssetEvent végpontok
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def _check_asset_access(
|
||||
db: AsyncSession,
|
||||
asset_id: uuid.UUID,
|
||||
current_user: User
|
||||
) -> Asset:
|
||||
"""
|
||||
Helper: check if the current user has access to the given asset.
|
||||
Returns the Asset or raises 404.
|
||||
"""
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
|
||||
if not asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found or you don't have permission to access it"
|
||||
)
|
||||
return asset
|
||||
|
||||
|
||||
async def _resolve_cost_category_for_event(db: AsyncSession, event_type: str) -> int:
|
||||
"""
|
||||
Helper: resolve a CostCategory ID based on the AssetEvent event_type.
|
||||
|
||||
Maps service-book event types to cost categories:
|
||||
- SERVICE, MAINTENANCE, INSPECTION, TIRE_CHANGE -> 'MAINTENANCE' (karbantartás)
|
||||
- REPAIR, ACCIDENT -> 'REPAIR' (javítás)
|
||||
- UPGRADE, RECALL -> 'UPGRADE' (fejlesztés)
|
||||
|
||||
Falls back to a generic 'MAINTENANCE' category if none found.
|
||||
"""
|
||||
category_map = {
|
||||
"SERVICE": "MAINTENANCE",
|
||||
"MAINTENANCE": "MAINTENANCE",
|
||||
"INSPECTION": "MAINTENANCE",
|
||||
"TIRE_CHANGE": "MAINTENANCE",
|
||||
"REPAIR": "REPAIR",
|
||||
"ACCIDENT": "REPAIR",
|
||||
"UPGRADE": "UPGRADE",
|
||||
"RECALL": "UPGRADE",
|
||||
}
|
||||
category_code = category_map.get(event_type, "MAINTENANCE")
|
||||
|
||||
stmt = select(CostCategory.id).where(
|
||||
CostCategory.code == category_code
|
||||
).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
cat_id = result.scalar_one_or_none()
|
||||
|
||||
if cat_id is None:
|
||||
# Fallback: try to find any category with 'MAINTENANCE' in name
|
||||
fallback_stmt = select(CostCategory.id).where(
|
||||
CostCategory.code.ilike("%MAINTENANCE%")
|
||||
).limit(1)
|
||||
fallback_result = await db.execute(fallback_stmt)
|
||||
cat_id = fallback_result.scalar_one_or_none()
|
||||
|
||||
if cat_id is None:
|
||||
# Ultimate fallback: pick the first available cost category
|
||||
any_stmt = select(CostCategory.id).limit(1)
|
||||
any_result = await db.execute(any_stmt)
|
||||
cat_id = any_result.scalar_one_or_none()
|
||||
|
||||
if cat_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="No cost category found in the system. Please seed cost categories first."
|
||||
)
|
||||
|
||||
return cat_id
|
||||
|
||||
|
||||
@router.get("/{asset_id}/events", response_model=List[AssetEventResponse])
|
||||
async def list_asset_events(
|
||||
asset_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Szervizkönyv események listázása.
|
||||
|
||||
GET /api/v1/assets/{asset_id}/events
|
||||
|
||||
Visszaadja a járműhöz tartozó összes AssetEvent rekordot,
|
||||
dátum szerint csökkenő sorrendben (legújabb elöl).
|
||||
"""
|
||||
# Jogosultság ellenőrzés
|
||||
await _check_asset_access(db, asset_id, current_user)
|
||||
|
||||
stmt = (
|
||||
select(AssetEvent)
|
||||
.options(selectinload(AssetEvent.cost))
|
||||
.where(AssetEvent.asset_id == asset_id)
|
||||
.order_by(desc(AssetEvent.event_date))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
events = result.scalars().all()
|
||||
return events
|
||||
|
||||
|
||||
@router.post("/{asset_id}/events", response_model=AssetEventResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_asset_event(
|
||||
asset_id: uuid.UUID,
|
||||
payload: AssetEventCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Új szervizkönyv esemény rögzítése.
|
||||
|
||||
POST /api/v1/assets/{asset_id}/events
|
||||
|
||||
**Üzleti logika:**
|
||||
- Ha a beküldött `odometer_reading` nagyobb, mint a jármű jelenlegi
|
||||
`current_mileage` értéke, akkor:
|
||||
1. Létrehoz egy új OdometerReading rekordot
|
||||
2. Frissíti a jármű `current_mileage` mezőjét
|
||||
- Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
|
||||
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
|
||||
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül.
|
||||
"""
|
||||
# Jogosultság ellenőrzés
|
||||
asset = await _check_asset_access(db, asset_id, current_user)
|
||||
|
||||
# Dátum: ha nincs megadva, használjuk a jelenlegi időt
|
||||
event_date = payload.event_date or datetime.now(timezone.utc)
|
||||
|
||||
# Szervezet meghatározása
|
||||
organization_id = asset.current_organization_id or asset.owner_org_id
|
||||
|
||||
try:
|
||||
# ── Üzleti logika: AssetCost automatikus létrehozása ──
|
||||
cost_id = payload.cost_id
|
||||
if payload.cost_amount is not None and payload.cost_amount > 0:
|
||||
# Költségkategória feloldása az esemény típusa alapján
|
||||
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
|
||||
cost_category_id = await _resolve_cost_category_for_event(db, payload.event_type)
|
||||
|
||||
currency = payload.currency or "HUF"
|
||||
|
||||
cost_record = AssetCost(
|
||||
asset_id=asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=cost_category_id,
|
||||
amount_net=payload.cost_amount,
|
||||
currency=currency,
|
||||
date=event_date,
|
||||
data={
|
||||
"description": payload.description or f"Service event: {payload.event_type}",
|
||||
"mileage_at_cost": payload.odometer_reading,
|
||||
"source": "service_book",
|
||||
"event_type": payload.event_type,
|
||||
},
|
||||
)
|
||||
db.add(cost_record)
|
||||
await db.flush() # Flush to get the cost_record.id
|
||||
cost_id = cost_record.id
|
||||
|
||||
logger.info(
|
||||
f"AssetCost auto-created for asset {asset_id}: "
|
||||
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})"
|
||||
)
|
||||
|
||||
# Új esemény létrehozása
|
||||
event = AssetEvent(
|
||||
asset_id=asset_id,
|
||||
user_id=current_user.id,
|
||||
organization_id=organization_id,
|
||||
event_type=payload.event_type,
|
||||
odometer_reading=payload.odometer_reading,
|
||||
description=payload.description,
|
||||
cost_id=cost_id,
|
||||
event_date=event_date,
|
||||
)
|
||||
db.add(event)
|
||||
|
||||
# ── Üzleti logika: Km óra állás frissítése ──
|
||||
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage:
|
||||
# 1. Új OdometerReading rekord létrehozása
|
||||
odometer = OdometerReading(
|
||||
asset_id=asset_id,
|
||||
reading=payload.odometer_reading,
|
||||
source="manual",
|
||||
)
|
||||
db.add(odometer)
|
||||
|
||||
# 2. Jármű current_mileage frissítése
|
||||
asset.current_mileage = payload.odometer_reading
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(
|
||||
f"Odometer updated for asset {asset_id}: "
|
||||
f"{asset.current_mileage} → {payload.odometer_reading} "
|
||||
f"(via event {payload.event_type})"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(event, attribute_names=["cost"])
|
||||
return event
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Asset event creation error for {asset_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba a szervizkönyv esemény létrehozásakor"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/vehicles/{vehicle_id}/archive", response_model=AssetResponse)
|
||||
async def archive_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
@@ -747,7 +987,7 @@ async def archive_vehicle(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Vehicle archive error for {vehicle_id}: {e}")
|
||||
logger.error(f"Vehicle archive error for {vehicle_id}: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Belső szerverhiba a jármű archiválásakor"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/billing.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy import select, func, and_
|
||||
from typing import Optional, Dict, Any, List
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User, Wallet, UserRole
|
||||
from app.models import FinancialLedger, WalletType
|
||||
from app.models import FinancialLedger, WalletType, LedgerEntryType, LedgerStatus
|
||||
from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus
|
||||
from app.services.config_service import config
|
||||
from app.services.payment_router import PaymentRouter
|
||||
@@ -311,4 +311,99 @@ async def get_wallet_balance(
|
||||
except Exception as e:
|
||||
logger.error(f"Pénztárca egyenleg lekérdezési hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/wallet/transactions")
|
||||
async def get_wallet_transactions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
page: int = Query(1, ge=1, description="Oldalszám"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Elemek száma oldalanként"),
|
||||
wallet_type: Optional[str] = Query(None, description="Szűrés pénztárca típusra (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)"),
|
||||
entry_type: Optional[str] = Query(None, description="Szűrés bejegyzés típusra (DEBIT, CREDIT)"),
|
||||
):
|
||||
"""
|
||||
Bejelentkezett felhasználó tranzakciótörténetének lekérdezése (FinancialLedger).
|
||||
|
||||
Visszaadja a főkönyvi bejegyzéseket lapozható formában, időrendben csökkenő sorrendben.
|
||||
"""
|
||||
try:
|
||||
# Base query
|
||||
conditions = [FinancialLedger.user_id == current_user.id]
|
||||
|
||||
# Optional wallet_type filter
|
||||
if wallet_type:
|
||||
try:
|
||||
wt = WalletType(wallet_type.upper())
|
||||
conditions.append(FinancialLedger.wallet_type == wt)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Érvénytelen wallet_type: {wallet_type}. Használd: {[wt.value for wt in WalletType]}"
|
||||
)
|
||||
|
||||
# Optional entry_type filter
|
||||
if entry_type:
|
||||
try:
|
||||
et = LedgerEntryType(entry_type.upper())
|
||||
conditions.append(FinancialLedger.entry_type == et)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Érvénytelen entry_type: {entry_type}. Használd: {[et.value for et in LedgerEntryType]}"
|
||||
)
|
||||
|
||||
# Count total matching records
|
||||
count_stmt = select(func.count(FinancialLedger.id)).where(and_(*conditions))
|
||||
count_result = await db.execute(count_stmt)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
# Fetch paginated results
|
||||
offset = (page - 1) * page_size
|
||||
stmt = (
|
||||
select(FinancialLedger)
|
||||
.where(and_(*conditions))
|
||||
.order_by(FinancialLedger.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
entries = result.scalars().all()
|
||||
|
||||
# Build response
|
||||
transactions = []
|
||||
for entry in entries:
|
||||
description = ""
|
||||
if entry.details and isinstance(entry.details, dict):
|
||||
description = entry.details.get("description", "")
|
||||
|
||||
transactions.append({
|
||||
"id": entry.id,
|
||||
"transaction_id": str(entry.transaction_id),
|
||||
"amount": float(entry.amount),
|
||||
"entry_type": entry.entry_type.value,
|
||||
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
|
||||
"transaction_type": entry.transaction_type,
|
||||
"description": description,
|
||||
"status": entry.status.value if entry.status else None,
|
||||
"balance_after": float(entry.balance_after) if entry.balance_after else None,
|
||||
"currency": entry.currency or "EUR",
|
||||
"created_at": entry.created_at.isoformat() if entry.created_at else None,
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": transactions,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_count": total_count,
|
||||
"total_pages": max(1, (total_count + page_size - 1) // page_size),
|
||||
},
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Tranzakció történet lekérdezési hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
87
backend/app/api/v1/endpoints/constants.py
Normal file
87
backend/app/api/v1/endpoints/constants.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Constants API Endpoint
|
||||
======================
|
||||
Serves static dictionaries (vehicle features, drive types, etc.)
|
||||
to the frontend so it doesn't need to hardcode them.
|
||||
"""
|
||||
from fastapi import APIRouter, Query
|
||||
from app.constants.vehicle_features import (
|
||||
CAR_FEATURES,
|
||||
CAR_DRIVE_TYPES,
|
||||
CAR_TRANSMISSION_TYPES,
|
||||
CAR_AC_TYPES,
|
||||
CAR_BODY_TYPES,
|
||||
AC_CONNECTOR_TYPES,
|
||||
DC_CONNECTOR_TYPES,
|
||||
MOTORCYCLE_STROKES,
|
||||
MOTORCYCLE_COOLING,
|
||||
MOTORCYCLE_MIXTURE,
|
||||
MOTORCYCLE_FEATURES,
|
||||
LCV_BODY_TYPES,
|
||||
LCV_FEATURES,
|
||||
HGV_TRANSMISSION_TYPES,
|
||||
HGV_PTO_TYPES,
|
||||
HGV_BODY_TYPES,
|
||||
HGV_FEATURES,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/vehicle-features")
|
||||
async def get_vehicle_features(
|
||||
type: str = Query(None, description="Filter by vehicle type: 'car', 'motorcycle', 'lcv' or 'hgv'")
|
||||
):
|
||||
"""Return all vehicle feature dictionaries for frontend use.
|
||||
|
||||
If ?type=motorcycle is provided, returns motorcycle-specific data.
|
||||
If ?type=lcv is provided, returns LCV-specific data.
|
||||
If ?type=hgv is provided, returns HGV-specific data.
|
||||
Otherwise returns car-specific data.
|
||||
"""
|
||||
if type == "motorcycle":
|
||||
return {
|
||||
**MOTORCYCLE_FEATURES, # Spread categories directly: technical, frame_body, luggage, multimedia, other
|
||||
"drive_types": CAR_DRIVE_TYPES,
|
||||
"transmission_types": CAR_TRANSMISSION_TYPES,
|
||||
"ac_types": {}, # No AC types for motorcycles
|
||||
"body_types": {}, # No body types for motorcycles
|
||||
"ac_connector_types": AC_CONNECTOR_TYPES,
|
||||
"dc_connector_types": DC_CONNECTOR_TYPES,
|
||||
"motorcycle_strokes": MOTORCYCLE_STROKES,
|
||||
"motorcycle_cooling": MOTORCYCLE_COOLING,
|
||||
"motorcycle_mixture": MOTORCYCLE_MIXTURE,
|
||||
}
|
||||
|
||||
if type == "lcv":
|
||||
return {
|
||||
**LCV_FEATURES, # Spread categories directly: technical, interior, exterior, multimedia
|
||||
"drive_types": CAR_DRIVE_TYPES,
|
||||
"transmission_types": CAR_TRANSMISSION_TYPES,
|
||||
"ac_types": CAR_AC_TYPES,
|
||||
"body_types": LCV_BODY_TYPES,
|
||||
"ac_connector_types": AC_CONNECTOR_TYPES,
|
||||
"dc_connector_types": DC_CONNECTOR_TYPES,
|
||||
}
|
||||
|
||||
if type == "hgv":
|
||||
return {
|
||||
**HGV_FEATURES, # Spread categories directly: technical_work, cabin, multimedia
|
||||
"drive_types": CAR_DRIVE_TYPES,
|
||||
"transmission_types": HGV_TRANSMISSION_TYPES,
|
||||
"pto_types": HGV_PTO_TYPES,
|
||||
"ac_types": CAR_AC_TYPES,
|
||||
"body_types": HGV_BODY_TYPES,
|
||||
"ac_connector_types": AC_CONNECTOR_TYPES,
|
||||
"dc_connector_types": DC_CONNECTOR_TYPES,
|
||||
}
|
||||
|
||||
return {
|
||||
**CAR_FEATURES, # Spread categories directly: technical, interior, exterior, multimedia, other
|
||||
"drive_types": CAR_DRIVE_TYPES,
|
||||
"transmission_types": CAR_TRANSMISSION_TYPES,
|
||||
"ac_types": CAR_AC_TYPES,
|
||||
"body_types": CAR_BODY_TYPES,
|
||||
"ac_connector_types": AC_CONNECTOR_TYPES,
|
||||
"dc_connector_types": DC_CONNECTOR_TYPES,
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models import Asset, AssetCost, OrganizationMember, SystemParameter
|
||||
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter
|
||||
from app.schemas.asset_cost import AssetCostCreate
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cost category IDs that are service/maintenance/repair related
|
||||
# These trigger automatic AssetEvent creation
|
||||
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -18,6 +25,10 @@ async def create_expense(
|
||||
"""
|
||||
Create a new expense (fuel, service, tax, insurance) for an asset.
|
||||
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
|
||||
|
||||
**Bidirectional Sync:**
|
||||
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES),
|
||||
an AssetEvent is automatically created and linked to the cost via cost_id.
|
||||
"""
|
||||
# Validate asset exists
|
||||
stmt = select(Asset).where(Asset.id == expense.asset_id)
|
||||
@@ -85,32 +96,87 @@ async def create_expense(
|
||||
if expense.description:
|
||||
data["description"] = expense.description
|
||||
|
||||
# Create AssetCost instance
|
||||
new_cost = AssetCost(
|
||||
asset_id=expense.asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=expense.category_id,
|
||||
amount_net=expense.amount_net,
|
||||
currency=expense.currency,
|
||||
date=expense.date,
|
||||
invoice_number=data.get("invoice_number"),
|
||||
data=data
|
||||
)
|
||||
try:
|
||||
# Create AssetCost instance
|
||||
new_cost = AssetCost(
|
||||
asset_id=expense.asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=expense.category_id,
|
||||
amount_net=expense.amount_net,
|
||||
currency=expense.currency,
|
||||
date=expense.date,
|
||||
invoice_number=data.get("invoice_number"),
|
||||
data=data
|
||||
)
|
||||
|
||||
db.add(new_cost)
|
||||
await db.flush() # Flush to get new_cost.id
|
||||
|
||||
# ── BIDIRECTIONAL SYNC: Auto-create AssetEvent for service-related costs ──
|
||||
event_id = None
|
||||
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
|
||||
# Map cost category to event type
|
||||
event_type = _map_category_to_event_type(expense.category_id)
|
||||
|
||||
description = expense.description or data.get("description", f"Service cost: {expense.category_id}")
|
||||
mileage = expense.mileage_at_cost
|
||||
|
||||
new_event = AssetEvent(
|
||||
asset_id=expense.asset_id,
|
||||
user_id=getattr(current_user, 'id', None),
|
||||
organization_id=organization_id,
|
||||
event_type=event_type,
|
||||
odometer_reading=mileage,
|
||||
description=description,
|
||||
cost_id=new_cost.id,
|
||||
event_date=expense.date or datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(new_event)
|
||||
await db.flush() # Flush to get new_event.id
|
||||
event_id = new_event.id
|
||||
|
||||
logger.info(
|
||||
f"Bidirectional sync: Auto-created AssetEvent {event_id} "
|
||||
f"for AssetCost {new_cost.id} (category_id={expense.category_id})"
|
||||
)
|
||||
|
||||
# Update Asset.current_mileage if mileage_at_cost is higher
|
||||
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
|
||||
asset.current_mileage = expense.mileage_at_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_cost)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"id": new_cost.id,
|
||||
"asset_id": new_cost.asset_id,
|
||||
"category_id": new_cost.category_id,
|
||||
"amount_net": new_cost.amount_net,
|
||||
"date": new_cost.date,
|
||||
"event_id": str(event_id) if event_id else None,
|
||||
}
|
||||
|
||||
db.add(new_cost)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Expense creation error for asset {expense.asset_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba a költség rögzítésekor"
|
||||
)
|
||||
|
||||
|
||||
def _map_category_to_event_type(category_id: int) -> str:
|
||||
"""
|
||||
Map a cost category ID to the appropriate AssetEvent event_type.
|
||||
|
||||
# Update Asset.current_mileage if mileage_at_cost is higher
|
||||
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
|
||||
asset.current_mileage = expense.mileage_at_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_cost)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"id": new_cost.id,
|
||||
"asset_id": new_cost.asset_id,
|
||||
"category_id": new_cost.category_id,
|
||||
"amount_net": new_cost.amount_net,
|
||||
"date": new_cost.date
|
||||
}
|
||||
Returns:
|
||||
str: The event type string (SERVICE, REPAIR, etc.)
|
||||
"""
|
||||
category_event_map = {
|
||||
2: "MAINTENANCE", # MAINTENANCE
|
||||
16: "SERVICE", # MAINT_SERVICE
|
||||
17: "SERVICE", # MAINT_OIL
|
||||
18: "REPAIR", # MAINT_BRAKES
|
||||
}
|
||||
return category_event_map.get(category_id, "MAINTENANCE")
|
||||
@@ -1,18 +1,165 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
"""
|
||||
Provider végpontok – Szolgáltató keresés és gyors felvétel (crowdsourced).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
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
|
||||
from app.api import deps
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.identity import User
|
||||
from app.models.marketplace.service import ExpertiseTag
|
||||
from app.schemas.provider import (
|
||||
ProviderSearchResponse,
|
||||
ProviderQuickAddIn,
|
||||
ProviderQuickAddResponse,
|
||||
ProviderUpdateIn,
|
||||
ProviderUpdateResponse,
|
||||
ExpertiseCategoryOut,
|
||||
)
|
||||
from app.services.provider_service import search_providers, quick_add_provider, update_provider
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Secured endpoint: Closed premium ecosystem
|
||||
@router.post("/", response_model=ServiceProviderResponse)
|
||||
async def add_provider(
|
||||
provider_data: ServiceProviderCreate,
|
||||
|
||||
@router.get("/categories", response_model=List[ExpertiseCategoryOut])
|
||||
async def list_expertise_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_user)
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
user_id = current_user.id
|
||||
return await create_service_provider(db, provider_data, user_id)
|
||||
"""
|
||||
Visszaadja az összes expertise kategóriát a frontend dropdown számára.
|
||||
"""
|
||||
try:
|
||||
stmt = select(ExpertiseTag).order_by(ExpertiseTag.id)
|
||||
result = await db.execute(stmt)
|
||||
tags = result.scalars().all()
|
||||
return [
|
||||
ExpertiseCategoryOut(
|
||||
id=t.id,
|
||||
key=t.key,
|
||||
name_hu=t.name_hu,
|
||||
name_en=t.name_en,
|
||||
category=t.category,
|
||||
)
|
||||
for t in tags
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a kategóriák lekérése során: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a kategóriák lekérése során.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/search", response_model=ProviderSearchResponse)
|
||||
async def search_service_providers(
|
||||
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"),
|
||||
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
|
||||
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Találatok száma oldalanként"),
|
||||
offset: int = Query(0, ge=0, description="Lapozási offset"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Szolgáltatók keresése egyesített forrásokból.
|
||||
|
||||
Három forrásból keres:
|
||||
- **verified_org**: Verifikált szervezetek (fleet.organizations, org_type='service_provider')
|
||||
- **staged_data**: Robotok által gyűjtött adatok (marketplace.service_staging)
|
||||
- **crowd_added**: Közösség által hozzáadott adatok (marketplace.service_providers)
|
||||
|
||||
A találatok forrás szerint csoportosítva, lapozva érkeznek.
|
||||
"""
|
||||
try:
|
||||
result = await search_providers(
|
||||
db=db,
|
||||
q=q,
|
||||
category=category,
|
||||
city=city,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a szolgáltató keresés során: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a keresés során.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/quick-add", response_model=ProviderQuickAddResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def quick_add_service_provider(
|
||||
data: ProviderQuickAddIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Gyors szolgáltató felvétel crowdsourcingból.
|
||||
|
||||
Létrehoz egy új Organization-t (is_verified=False, org_type='service_provider'),
|
||||
hozzáköt egy ServiceProfile-t, a megadott kategóriát (expertise_tags),
|
||||
létrehoz egy Branch-t a címmel, és a felhasználót beállítja OWNER-nek.
|
||||
|
||||
A szolgáltató 'pending_verification' státuszba kerül, amíg a robotok
|
||||
vagy az adminok nem ellenőrzik.
|
||||
"""
|
||||
try:
|
||||
result = await quick_add_provider(
|
||||
db=db,
|
||||
data=data,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a gyors szolgáltató felvétel során: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a szolgáltató rögzítése során.",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{provider_id}", response_model=ProviderUpdateResponse)
|
||||
async def update_service_provider(
|
||||
provider_id: int,
|
||||
data: ProviderUpdateIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Meglévő szolgáltató (Organization) adatainak szerkesztése.
|
||||
|
||||
Frissíti a szervezet alapadatait (név, cím) és a kapcsolódó
|
||||
ServiceProfile kapcsolati mezőit (telefon, email, weboldal, címkék).
|
||||
"""
|
||||
try:
|
||||
result = await update_provider(
|
||||
db=db,
|
||||
provider_id=provider_id,
|
||||
data=data,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Hiba a szolgáltató frissítése során (id={provider_id}): {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba történt a szolgáltató frissítése során.",
|
||||
)
|
||||
|
||||
40
backend/app/constants/__init__.py
Normal file
40
backend/app/constants/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Constants Package
|
||||
=================
|
||||
Static dictionaries for vehicle features, drive types, etc.
|
||||
"""
|
||||
from app.constants.vehicle_features import (
|
||||
CAR_FEATURES,
|
||||
CAR_DRIVE_TYPES,
|
||||
CAR_TRANSMISSION_TYPES,
|
||||
CAR_AC_TYPES,
|
||||
CAR_BODY_TYPES,
|
||||
AC_CONNECTOR_TYPES,
|
||||
DC_CONNECTOR_TYPES,
|
||||
MOTORCYCLE_STROKES,
|
||||
MOTORCYCLE_COOLING,
|
||||
MOTORCYCLE_MIXTURE,
|
||||
MOTORCYCLE_FEATURES,
|
||||
get_all_feature_keys,
|
||||
get_feature_label,
|
||||
get_features_by_category,
|
||||
get_motorcycle_feature_label,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CAR_FEATURES",
|
||||
"CAR_DRIVE_TYPES",
|
||||
"CAR_TRANSMISSION_TYPES",
|
||||
"CAR_AC_TYPES",
|
||||
"CAR_BODY_TYPES",
|
||||
"AC_CONNECTOR_TYPES",
|
||||
"DC_CONNECTOR_TYPES",
|
||||
"MOTORCYCLE_STROKES",
|
||||
"MOTORCYCLE_COOLING",
|
||||
"MOTORCYCLE_MIXTURE",
|
||||
"MOTORCYCLE_FEATURES",
|
||||
"get_all_feature_keys",
|
||||
"get_feature_label",
|
||||
"get_features_by_category",
|
||||
"get_motorcycle_feature_label",
|
||||
]
|
||||
759
backend/app/constants/vehicle_features.py
Normal file
759
backend/app/constants/vehicle_features.py
Normal file
@@ -0,0 +1,759 @@
|
||||
"""
|
||||
Vehicle Features & EV Constants
|
||||
================================
|
||||
Centralized dictionaries for car-specific dropdowns, EV connector types,
|
||||
and categorized feature lists. All data is stored in the `individual_equipment`
|
||||
JSONB column on the Asset model.
|
||||
|
||||
Usage:
|
||||
from app.constants.vehicle_features import (
|
||||
CAR_DRIVE_TYPES, CAR_TRANSMISSION_TYPES, CAR_AC_TYPES,
|
||||
CAR_BODY_TYPES, CAR_FEATURES, AC_CONNECTOR_TYPES, DC_CONNECTOR_TYPES,
|
||||
MOTORCYCLE_STROKES, MOTORCYCLE_COOLING, MOTORCYCLE_MIXTURE,
|
||||
MOTORCYCLE_FEATURES,
|
||||
LCV_BODY_TYPES, LCV_FEATURES,
|
||||
HGV_TRANSMISSION_TYPES, HGV_PTO_TYPES, HGV_BODY_TYPES, HGV_FEATURES,
|
||||
)
|
||||
"""
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# CAR-SPECIFIC DROPDOWNS (only shown when vehicle_class == 'personal')
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
CAR_DRIVE_TYPES: dict[str, str] = {
|
||||
"fwd": "Elsőkerék (FWD)",
|
||||
"rwd": "Hátsókerék (RWD)",
|
||||
"awd": "Összkerék (AWD / 4×4)",
|
||||
"4wd": "Összkerék (4WD / kapcsolható)",
|
||||
}
|
||||
|
||||
CAR_TRANSMISSION_TYPES: dict[str, str] = {
|
||||
"manual": "Manuális",
|
||||
"automatic": "Automata",
|
||||
"sequential": "Szekvenciális",
|
||||
"cvt": "CVT (Fokozatmentes)",
|
||||
"dct": "DCT (Dupla kuplungos)",
|
||||
"semi_auto": "Félautomata",
|
||||
}
|
||||
|
||||
CAR_AC_TYPES: dict[str, str] = {
|
||||
"manual": "Manuális klíma",
|
||||
"automatic": "Automata klíma",
|
||||
"dual_zone": "Kétzónás automata klíma",
|
||||
"tri_zone": "Háromzónás automata klíma",
|
||||
"quad_zone": "Négyzónás automata klíma",
|
||||
}
|
||||
|
||||
CAR_BODY_TYPES: dict[str, str] = {
|
||||
"sedan": "Szedán",
|
||||
"hatchback": "Ferdehátú (Hatchback)",
|
||||
"wagon": "Kombi (Estate)",
|
||||
"coupe": "Coupé",
|
||||
"convertible": "Kabrió (Convertible)",
|
||||
"suv": "SUV / Szabadidő-autó",
|
||||
"mpv": "MPV (Egyterű)",
|
||||
"pickup": "Pickup",
|
||||
"van": "Furgon (Van)",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# CAR FEATURES / EXTRAS (categorized)
|
||||
# Stored in individual_equipment.car_specs.features as a list of keys.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
CAR_FEATURES: dict[str, dict[str, str]] = {
|
||||
"technical": {
|
||||
"abs": "ABS",
|
||||
"esp": "ESP (Menetstabilizáló)",
|
||||
"traction_control": "Kipörgésgátló (TCS)",
|
||||
"hill_assist": "Hegymeneti tartó",
|
||||
"hill_descent": "Lejtmenetvezérlő",
|
||||
"lane_assist": "Sávtartó asszisztens",
|
||||
"blind_spot": "Holtpont-figyelő",
|
||||
"adaptive_cruise": "Adaptív tempomat (ACC)",
|
||||
"parking_sensors_front": "Első parkolóérzékelők",
|
||||
"parking_sensors_rear": "Hátsó parkolóérzékelők",
|
||||
"rear_camera": "Tolatókamera",
|
||||
"360_camera": "360°-os kamera",
|
||||
"auto_parking": "Automatikus parkolóasszisztens",
|
||||
"start_stop": "Start-Stop rendszer",
|
||||
"keyless_go": "Kulcs nélküli indítás (Keyless Go)",
|
||||
"night_vision": "Éjjellátó (Night Vision)",
|
||||
"traffic_sign_recognition": "Táblafelismerő rendszer",
|
||||
"driver_alert": "Fáradtságfigyelő",
|
||||
"emergency_brake": "Vészfék asszisztens",
|
||||
"cross_traffic_alert": "Keresztirányú forgalom figyelő",
|
||||
},
|
||||
"interior": {
|
||||
"leather_seats": "Bőrülés",
|
||||
"heated_seats_front": "Első ülésfűtés",
|
||||
"heated_seats_rear": "Hátsó ülésfűtés",
|
||||
"ventilated_seats": "Szellőztetett ülések",
|
||||
"massage_seats": "Masszázsülés",
|
||||
"electric_seats": "Elektromos ülésállítás",
|
||||
"memory_seats": "Ülésmemória",
|
||||
"sport_seats": "Sportülés",
|
||||
"heated_steering": "Fűthető kormány",
|
||||
"ambient_lighting": "Hangulatvilágítás (Ambient Light)",
|
||||
"panoramic_roof": "Panorámatető",
|
||||
"sunroof": "Napfénytető",
|
||||
"auto_dim_mirror": "Automatikusan sötétedő belső tükör",
|
||||
"electric_steering_adjust": "Elektromos kormányállítás",
|
||||
"rear_blind": "Hátsó roló",
|
||||
"rear_side_blinds": "Hátsó oldalsó rolók",
|
||||
"digital_instrument_cluster": "Digitális műszeregység",
|
||||
"head_up_display": "Head-Up Display (HUD)",
|
||||
},
|
||||
"exterior": {
|
||||
"alloy_wheels": "Könnyűfém felni",
|
||||
"roof_rails": "Tetősín (Roof Rails)",
|
||||
"tow_bar": "Vontatóhorog (vonóhorog)",
|
||||
"tinted_windows": "Sötétített üvegek",
|
||||
"privacy_glass": "Privacy Glass",
|
||||
"led_headlights": "LED fényszóró",
|
||||
"adaptive_headlights": "Adaptív fényszóró (AFS)",
|
||||
"fog_lights": "Ködlámpa",
|
||||
"cornering_lights": "Kanyarvilágítás",
|
||||
"rain_sensor": "Esőérzékelő",
|
||||
"light_sensor": "Fényérzékelő",
|
||||
"auto_wipers": "Automatikus ablaktörlő",
|
||||
"electric_trunk": "Elektromos csomagtérajtó",
|
||||
"hands_free_trunk": "Kihangosítható csomagtérajtó (Hands-Free)",
|
||||
"side_steps": "Oldallépcső",
|
||||
"mudguards": "Sárvédő",
|
||||
},
|
||||
"multimedia": {
|
||||
"navigation": "Navigáció (GPS)",
|
||||
"apple_carplay": "Apple CarPlay",
|
||||
"android_auto": "Android Auto",
|
||||
"bluetooth": "Bluetooth",
|
||||
"usb_charging": "USB töltőcsatlakozó",
|
||||
"wireless_charging": "Vezeték nélküli töltő",
|
||||
"dab_radio": "DAB+ digitális rádió",
|
||||
"premium_sound": "Prémium hangrendszer",
|
||||
"subwoofer": "Mélynyomó (Subwoofer)",
|
||||
"rear_entertainment": "Hátsó szórakoztató rendszer",
|
||||
"wifi_hotspot": "WiFi Hotspot",
|
||||
"voice_control": "Hangvezérlés",
|
||||
"gesture_control": "Gesztusvezérlés",
|
||||
},
|
||||
"other": {
|
||||
"spare_tire": "Pótkerék",
|
||||
"tire_repair_kit": "Kerékpár-javító készlet",
|
||||
"first_aid_kit": "Elsősegély csomag",
|
||||
"warning_triangle": "Figyelmeztető háromszög",
|
||||
"fire_extinguisher": "Tűzoltó készülék",
|
||||
"winter_tires": "Téli gumi",
|
||||
"cargo_net": "Csomagtér háló",
|
||||
"cargo_cover": "Csomagtér takaró",
|
||||
"pet_barrier": "Kutya rács / elválasztó",
|
||||
"roof_box": "Tetős doboz",
|
||||
"bike_rack": "Kerékpártartó",
|
||||
"ski_rack": "Sítartó",
|
||||
},
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# EV-SPECIFIC CONNECTOR TYPES
|
||||
# Stored in individual_equipment.ev_specs
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
AC_CONNECTOR_TYPES: dict[str, str] = {
|
||||
"type1": "Type 1 (SAE J1772)",
|
||||
"type2": "Type 2 (Mennekes)",
|
||||
"type3": "Type 3 (SCAME)",
|
||||
"gb_t": "GB/T (Kínai szabvány)",
|
||||
}
|
||||
|
||||
DC_CONNECTOR_TYPES: dict[str, str] = {
|
||||
"ccs": "CCS (Combined Charging System)",
|
||||
"chademo": "CHAdeMO",
|
||||
"tesla_supercharger": "Tesla Supercharger",
|
||||
"gb_t_dc": "GB/T DC (Kínai gyorstöltő)",
|
||||
"nacs": "NACS (Tesla North American)",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# MOTORCYCLE-SPECIFIC DROPDOWNS
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
MOTORCYCLE_STROKES: dict[str, str] = {
|
||||
"2_stroke": "2 ütemű",
|
||||
"4_stroke": "4 ütemű",
|
||||
"electric": "Elektromos",
|
||||
}
|
||||
|
||||
MOTORCYCLE_COOLING: dict[str, str] = {
|
||||
"air": "Levegőhűtés",
|
||||
"liquid": "Vízhűtés",
|
||||
"oil": "Olajhűtés",
|
||||
"air_oil": "Levegő/Olaj hűtés",
|
||||
}
|
||||
|
||||
MOTORCYCLE_MIXTURE: dict[str, str] = {
|
||||
"carburetor": "Karburátor",
|
||||
"injection": "Befecskendezés",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# MOTORCYCLE FEATURES / EXTRAS (categorized)
|
||||
# Stored in individual_equipment.motorcycle_specs.features as a list of keys.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
MOTORCYCLE_FEATURES: dict[str, dict[str, str]] = {
|
||||
"technical": {
|
||||
"dual_front_disc": "Dupla tárcsafék elöl",
|
||||
"front_disc": "Tárcsafék elöl",
|
||||
"rear_disc": "Tárcsafék hátul",
|
||||
"chip_tuning": "Chiptuning",
|
||||
"electronic_suspension": "Elektromos futómű állítás",
|
||||
"onboard_computer": "Fedélzeti computer",
|
||||
"metal_brake_line": "Fém fékcső",
|
||||
"rev_counter": "Fordulatszámmérő",
|
||||
"immobiliser": "Immobiliser",
|
||||
"catalyst": "Katalizátor",
|
||||
"electric_starter": "Önindító",
|
||||
"all_wheel_drive": "Összkerékhajtás",
|
||||
"alarm": "Riasztó",
|
||||
"sport_exhaust": "Sport kipufogó",
|
||||
"sport_air_filter": "Sport légszűrő",
|
||||
"cruise_control": "Tempomat",
|
||||
"turbo": "Turbó",
|
||||
"12v_system": "12 V rendszer",
|
||||
"heated_grip": "Markolat fűtés",
|
||||
"abs": "ABS (blokkolásgátló)",
|
||||
"seat_belt": "Biztonsági öv",
|
||||
"dtc": "DTC",
|
||||
"fog_light": "Ködlámpa",
|
||||
"airbag": "Légzsák",
|
||||
"xenon_headlight": "Xenon fényszóró",
|
||||
},
|
||||
"frame_body": {
|
||||
"full_extra": "Full extra",
|
||||
"leather_seat": "Bőrülés",
|
||||
"heated_seat": "Fűthető ülés",
|
||||
"backrest": "Háttámla",
|
||||
"center_stand": "Középsztender",
|
||||
"footrest": "Lábtartó",
|
||||
"windshield": "Motoros szélvédő",
|
||||
"plexiglass": "Plexi",
|
||||
"tank_pad": "Tankpad",
|
||||
"tank_protector_leather": "Tankvédő bőr",
|
||||
"seat_height_adjust": "Ülésmagasság állítás",
|
||||
"crash_bar": "Bukócső / bukógomba",
|
||||
"hand_guards": "Kézvédők",
|
||||
"heated_mirror": "Fűthető tükör",
|
||||
"tow_hitch": "Vonóhorog",
|
||||
},
|
||||
"luggage": {
|
||||
"factory_cases": "Gyári dobozok",
|
||||
"rear_case": "Hátsó doboz",
|
||||
"side_cases": "Oldalsó dobozok",
|
||||
"lockable_case": "Zárható doboz",
|
||||
"side_bag": "Oldaltáska",
|
||||
"tank_bag": "Tank táska",
|
||||
"bag_holder_bracket": "Táskatartó konzol",
|
||||
"fork_bag": "Villatáska",
|
||||
},
|
||||
"multimedia": {
|
||||
"cd_player": "CD tár",
|
||||
"gps_navigation": "GPS (navigáció)",
|
||||
"hi_fi": "Hi-Fi",
|
||||
"radio": "Rádiós magnó",
|
||||
"info_display": "Információs kijelző",
|
||||
},
|
||||
"other": {
|
||||
"under_warranty": "Garanciális",
|
||||
"us_model": "Amerikai modell",
|
||||
"immediate_takeover": "Azonnal elvihető",
|
||||
"showroom_vehicle": "Bemutató jármű",
|
||||
"orderable": "Rendelhető",
|
||||
"car_trade_in_possible": "Autóbeszámítás lehetséges",
|
||||
"first_owner": "Első tulajdonostól",
|
||||
"garage_kept": "Garázsban tartott",
|
||||
"female_owner": "Hölgy tulajdonostól",
|
||||
"low_mileage": "Keveset futott",
|
||||
"second_owner": "Második tulajdonostól",
|
||||
"motorcycle_trade_in_possible": "Motorbeszámítás lehetséges",
|
||||
"track_fairing": "Pályaidom",
|
||||
"regularly_maintained": "Rendszeresen karbantartott",
|
||||
"service_book": "Szervizkönyv",
|
||||
"title_certificate": "Törzskönyv",
|
||||
},
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# LCV-SPECIFIC BODY TYPES
|
||||
# Shown when vehicle_class == 'lcv'
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
LCV_BODY_TYPES: dict[str, str] = {
|
||||
"closed_van": "Zárt furgon",
|
||||
"pickup": "Pickup",
|
||||
"double_cab_chassis": "Dupla kabin alváz",
|
||||
"single_cab_chassis": "Egyes kabin alváz",
|
||||
"box_tail_lift": "Dobozos raktér emelővel",
|
||||
"tipper": "Billenős plató",
|
||||
"dropside": "Billenő oldalfalú plató",
|
||||
"refrigerated_van": "Hűtött furgon",
|
||||
"crew_cab_pickup": "Crew Cab Pickup",
|
||||
"window_van": "Ablakos furgon",
|
||||
"combi_van": "Combi furgon",
|
||||
"platform_truck": "Platós teherautó",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# LCV FEATURES / EXTRAS (categorized)
|
||||
# Stored in individual_equipment.lcv_specs.features as a list of keys.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
LCV_FEATURES: dict[str, dict[str, str]] = {
|
||||
"technical": {
|
||||
"abs": "ABS",
|
||||
"asr": "ASR (Kipörgésgátló)",
|
||||
"gps_tracker": "GPS nyomkövető",
|
||||
"immobiliser": "Immobiliser",
|
||||
"alarm": "Riasztó",
|
||||
"cruise_control": "Tempomat",
|
||||
"parking_sensors_rear": "Hátsó parkolóérzékelők",
|
||||
},
|
||||
"interior": {
|
||||
"curtain_airbag": "Függönylégzsák",
|
||||
"rear_side_airbag": "Hátsó oldallégzsák",
|
||||
"switchable_airbag": "Kikapcsolható utaslégzsák",
|
||||
"side_airbag": "Oldallégzsák",
|
||||
"passenger_airbag": "Utaslégzsák",
|
||||
"driver_airbag": "Vezető légzsák",
|
||||
"roll_bar": "Bukókeret",
|
||||
"cargo_tie_down": "Rögzítő pontok a raktérben",
|
||||
"isofix": "Isofix gyerekülés rögzítés",
|
||||
"full_extra": "Full extra",
|
||||
"auxiliary_heating": "Kiegészítő fűtés (Webasto)",
|
||||
"leather_interior": "Bőr belső",
|
||||
"heated_seat": "Fűthető ülés",
|
||||
"partition_wall": "Válaszfal",
|
||||
"seat_height_adjustment": "Ülésmagasság állítás",
|
||||
"adjustable_steering_wheel": "Állítható kormány",
|
||||
"central_locking": "Központi zár",
|
||||
"trip_computer": "Fedélzeti számítógép",
|
||||
"power_steering": "Szervokormány",
|
||||
},
|
||||
"exterior": {
|
||||
"power_windows": "Elektromos ablakemelő",
|
||||
"power_mirrors": "Elektromos tükör állítás",
|
||||
"heated_mirrors": "Fűthető tükör",
|
||||
"alloy_wheels": "Könnyűfém felni",
|
||||
"tinted_glass": "Sötétített üvegek",
|
||||
"tow_bar": "Vontatóhorog",
|
||||
"sunroof": "Napfénytető",
|
||||
"fog_lights": "Ködlámpa",
|
||||
"xenon_headlights": "Xenon fényszóró",
|
||||
},
|
||||
"multimedia": {
|
||||
"cd_changer": "CD váltó",
|
||||
"cd_radio": "CD rádió",
|
||||
"gps": "GPS navigáció",
|
||||
"hifi": "Hi-Fi hangrendszer",
|
||||
"radio_cassette": "Rádiós kazettás magnó",
|
||||
},
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# HGV-SPECIFIC DROPDOWNS (shown when vehicle_class == 'hgv')
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
HGV_TRANSMISSION_TYPES: dict[str, str] = {
|
||||
"manual": "Manuális",
|
||||
"manual_async": "Manuális (aszinkron)",
|
||||
"semi_auto": "Félautomata",
|
||||
"auto": "Automata",
|
||||
"manual_split": "Manuális felezős",
|
||||
"semi_auto_split": "Félautomata felezős",
|
||||
"auto_split": "Automata felezős",
|
||||
"cvt": "Fokozatmentes (CVT)",
|
||||
}
|
||||
|
||||
HGV_PTO_TYPES: dict[str, str] = {
|
||||
"front": "Első kihajtás (Front)",
|
||||
"engine_mounted": "Motorra szerelt (Engine-mounted)",
|
||||
"gearbox_mounted": "Váltóra szerelt (Gearbox-mounted)",
|
||||
}
|
||||
|
||||
# ── HGV Body Types (unified with frontend i18n keys) ──
|
||||
HGV_BODY_TYPES: dict[str, str] = {
|
||||
# ── Alváz / Fülke típusok ──
|
||||
"chassis": "Alváz (csak futómű)",
|
||||
"chassis_cab": "Alvázas fülkés (Chassis Cab)",
|
||||
"double_cab": "Duplakabinos (Double Cab)",
|
||||
"triple_cab": "Triplakabinos (Triple Cab)",
|
||||
"crew_cab": "Crew Cab",
|
||||
"sleeper_cab": "Alvófülkés (Sleeper Cab)",
|
||||
"day_cab": "Nappali fülke (Day Cab)",
|
||||
"glider_kit": "Glider Kit (felújított)",
|
||||
"semi_glazed": "Félig üvegezett (Semi-Glazed)",
|
||||
"fully_glazed": "Körben üvegezett (Fully Glazed)",
|
||||
|
||||
# ── Billenős / Borítás ──
|
||||
"tipper": "Billenős (Tipper)",
|
||||
"tipper_3_way": "3 irányba billenő",
|
||||
"tipper_double": "Kétoldalt billenő",
|
||||
"tipper_trailer": "Billenős pótkocsi",
|
||||
"tipper_mining": "Bányászati billenő (Mining Tipper)",
|
||||
"tipper_grain": "Gabonás billenő (Grain Tipper)",
|
||||
"dump_truck": "Billenős teherautó (Dump Truck)",
|
||||
|
||||
# ── Dobozos / Ponyvás ──
|
||||
"box": "Dobozos (Box)",
|
||||
"box_tail_lift": "Dobozos emelőhátfalas (Box with Tail Lift)",
|
||||
"box_insulated": "Szigetelt dobozos (Insulated Box)",
|
||||
"box_curtain_side": "Függönyoldalas dobozos",
|
||||
"curtain_sider": "Ponyvás (Curtain Sider)",
|
||||
"curtain_sider_roller": "Ponyvás rolós (Roller Curtain)",
|
||||
"roller_shutter": "Redőnyös (Roller Shutter)",
|
||||
|
||||
# ── Platós ──
|
||||
"flatbed": "Platós (Flatbed)",
|
||||
"flatbed_stake": "Rakodóléces platós",
|
||||
"flatbed_dropside": "Billenő oldalfalú platós",
|
||||
"flatbed_crane": "Platós darugémes (Flatbed with Crane)",
|
||||
"flatbed_tail_lift": "Platós emelőhátfalas (Flatbed with Tail Lift)",
|
||||
|
||||
# ── Hűtött ──
|
||||
"refrigerated": "Hűtős (Refrigerated / Reefer)",
|
||||
"refrigerated_trailer": "Hűtött pótkocsi",
|
||||
|
||||
# ── Tartályos ──
|
||||
"tanker": "Tartályos (Tanker)",
|
||||
"tanker_fuel": "Üzemanyag szállító (Fuel Tanker)",
|
||||
"tanker_milk": "Tej szállító tartály",
|
||||
"tanker_chemical": "Vegyi anyag szállító tartály",
|
||||
"tanker_powder": "Poranyag szállító tartály",
|
||||
"tanker_bitumen": "Bitumenes tartály (Bitumen Tanker)",
|
||||
"tanker_gas": "Gázszállító tartály (Gas Tanker)",
|
||||
"tanker_food": "Élelmiszer tartály (Food Tanker)",
|
||||
"tanker_water": "Víztartály (Water Tanker)",
|
||||
|
||||
# ── Nyergesvontató / Vontató ──
|
||||
"tractor_unit": "Nyergesvontató (Tractor Unit)",
|
||||
"tractor_unit_4x2": "Vontató 4x2",
|
||||
"tractor_unit_6x2": "Vontató 6x2",
|
||||
"tractor_unit_6x4": "Vontató 6x4",
|
||||
"tractor_unit_8x4": "Vontató 8x4",
|
||||
|
||||
# ── Félpótkocsi / Nyerges szerelvények ──
|
||||
"semi_trailer": "Nyerges szerelvény (Semi-Trailer)",
|
||||
"semi_trailer_curtain": "Függönyoldalas félpótkocsi",
|
||||
"semi_trailer_box": "Dobozos félpótkocsi",
|
||||
"semi_trailer_tipper": "Billenős félpótkocsi",
|
||||
"semi_trailer_tanker": "Tartályos félpótkocsi",
|
||||
"semi_trailer_flatbed": "Platós félpótkocsi",
|
||||
"semi_trailer_low_loader": "Mélyágyas félpótkocsi",
|
||||
"semi_trailer_car_transporter": "Autószállító félpótkocsi",
|
||||
"semi_tipper": "Nyerges billenő (Semi-Tipper)",
|
||||
"semi_refrigerated": "Nyerges hűtős (Semi-Reefer)",
|
||||
"semi_tanker": "Nyerges tartály (Semi-Tanker)",
|
||||
"semi_flatbed": "Nyerges platós (Semi-Flatbed)",
|
||||
"semi_curtain": "Nyerges ponyvás (Semi-Curtain)",
|
||||
"semi_log": "Nyerges rönkszállító (Semi-Timber)",
|
||||
"semi_container": "Nyerges konténerszállító (Semi-Container)",
|
||||
|
||||
# ── Konténer / Csereszekrény ──
|
||||
"container": "Konténer",
|
||||
"container_carrier": "Konténerszállító (Container Carrier)",
|
||||
"swap_body": "Cserélhető felépítmény",
|
||||
"swap_body_bdf": "Csereszekrényes BDF (Swap Body)",
|
||||
"hook_lift": "Emelőhorgos (Hook Lift)",
|
||||
|
||||
# ── Darus / Speciális ──
|
||||
"crane": "Daru",
|
||||
"crane_truck": "Darugémes teherautó (Crane Truck)",
|
||||
"crane_trailer": "Darus pótkocsi",
|
||||
"auto_crane": "Autódaru (Auto Crane)",
|
||||
"ladder_truck": "Létrás (Ladder Truck)",
|
||||
|
||||
# ── Tűzoltó / Mentő ──
|
||||
"fire_truck": "Tűzoltó (Fire Truck)",
|
||||
"fire_truck_aerial": "Léptetőkosaras tűzoltó",
|
||||
"ambulance": "Mentő (Ambulance)",
|
||||
"hearse": "Halottas (Hearse)",
|
||||
|
||||
# ── Páncélozott / Pénzszállító ──
|
||||
"armored": "Pénzszállító / páncélozott (Armored)",
|
||||
"cash_transit": "Pénzszállító (Cash-in-Transit)",
|
||||
|
||||
# ── Állatszállító ──
|
||||
"livestock": "Élőállat-szállító (Livestock)",
|
||||
"poultry": "Baromfi szállító (Poultry)",
|
||||
"horse_transporter": "Lószállító (Horse Transporter)",
|
||||
|
||||
# ── Járműszállító ──
|
||||
"car_transporter": "Járműszállító (Car Transporter)",
|
||||
"boat_transporter": "Hajószállító (Boat Transporter)",
|
||||
"quad_transporter": "Quad / motorszállító (Quad Transporter)",
|
||||
|
||||
# ── Egyéb speciális ──
|
||||
"mobile_shop": "Mozgóbolt (Mobile Shop)",
|
||||
"workshop": "Műhelykocsi (Workshop)",
|
||||
"tow_truck": "Mentő / Szállító (Wrecker / Recovery)",
|
||||
"wrecker": "Mentő / szállító (Wrecker / Recovery)",
|
||||
"street_sweeper": "Utcaseprő",
|
||||
"refuse_truck": "Szemetes (Refuse Truck)",
|
||||
"concrete_mixer": "Betonszállító (Concrete Mixer)",
|
||||
"concrete_pump": "Betonszivattyú (Concrete Pump)",
|
||||
"timber_truck": "Faszállító (Timber Truck)",
|
||||
"log_trailer": "Farönk szállító pótkocsi (Log Trailer)",
|
||||
"clothing_transporter": "Ruhaszállító (Clothing Transporter)",
|
||||
"military": "Harci jármű (Military)",
|
||||
|
||||
# ── Egyéb ──
|
||||
"other": "Egyéb (Other)",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# HGV FEATURES / EXTRAS (categorized)
|
||||
# Stored in individual_equipment.hgv_specs.features as a list of keys.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
HGV_FEATURES: dict[str, dict[str, str]] = {
|
||||
"technical_work": {
|
||||
# ═══════════════════════════════════════════════
|
||||
# 1. Hajtás & Fékrendszer (Drivetrain & Brakes)
|
||||
# ═══════════════════════════════════════════════
|
||||
"diff_lock": "Differenciálzár (Diff Lock)",
|
||||
"diff_lock_front": "Első differenciálzár",
|
||||
"diff_lock_rear": "Hátsó differenciálzár",
|
||||
"diff_lock_center": "Központi differenciálzár",
|
||||
"diff_lock_full": "Teljes differenciálzár",
|
||||
"cross_axle_lock": "Kereszttengely differenciálzár",
|
||||
"retarder": "Retarder (Lejtőfékező)",
|
||||
"engine_brake": "Motorfék (Engine Brake)",
|
||||
"exhaust_brake": "Kipufogófék (Exhaust Brake)",
|
||||
"auxiliary_brake": "Segédfék",
|
||||
"abs": "ABS (Blokkolásgátló)",
|
||||
"ebs": "EBS (Elektronikus fékrendszer)",
|
||||
"esp": "ESP (Menetstabilizáló)",
|
||||
"traction_control": "Kipörgésgátló (ASR/TCS)",
|
||||
"roll_stability": "Borulásgátló (RSP)",
|
||||
"stability_control": "Stabilitás szabályzó (ESP)",
|
||||
"hill_holder": "Hegymeneti tartó (Hill Holder)",
|
||||
"hill_descent": "Lejtmenetvezérlő (Hill Descent)",
|
||||
"hill_descent_control": "Lejtmenetvezérlő",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 2. Vezetéstámogató rendszerek (ADAS)
|
||||
# ═══════════════════════════════════════════════
|
||||
"adaptive_cruise": "Adaptív tempomat (ACC)",
|
||||
"lane_departure": "Sávelhagyás figyelő (LDWS)",
|
||||
"lane_assist": "Sávtartó asszisztens",
|
||||
"blind_spot": "Holtpont-figyelő (Blind Spot)",
|
||||
"blind_spot_monitor": "Holtpont figyelő",
|
||||
"collision_warning": "Ütközés figyelmeztető",
|
||||
"emergency_brake": "Vészfék asszisztens (AEBS)",
|
||||
"emergency_brake_assist": "Vészfék asszisztens",
|
||||
"traffic_sign": "Táblafelismerő rendszer",
|
||||
"traffic_sign_recognition": "Táblafelismerő",
|
||||
"driver_alert": "Fáradtságfigyelő (Driver Alert)",
|
||||
"crosswind_assist": "Keresztszél asszisztens",
|
||||
"tyre_pressure_monitor": "Guminyomás monitor (TPMS)",
|
||||
"tire_pressure": "Guminyomás-figyelő (TPMS)",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 3. Flotta Adminisztráció & Mérés
|
||||
# ═══════════════════════════════════════════════
|
||||
"tachograph": "Menetíró (Tachograph)",
|
||||
"digital_tachograph": "Digitális menetíró",
|
||||
"smart_tachograph": "Smart Tachográf (G2V2)",
|
||||
"gps_tracker": "GPS nyomkövető",
|
||||
"fleet_management": "Flottamenedzsment rendszer",
|
||||
"fuel_monitoring": "Üzemanyag monitorozás",
|
||||
"fuel_card_reader": "Üzemanyagkártya-olvasó",
|
||||
"onboard_scale": "Fedélzeti mérleg (Onboard Scale)",
|
||||
"weigh_in_motion": "Menet közbeni mérlegelés",
|
||||
"eco_driving": "Eco Driving asszisztens",
|
||||
"load_securing": "Rakományrögzítő rendszer",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 4. Látás & Kamerák
|
||||
# ═══════════════════════════════════════════════
|
||||
"reverse_camera": "Tolatókamera",
|
||||
"rear_camera": "Hátsó kamera",
|
||||
"front_camera": "Első kamera",
|
||||
"side_camera": "Oldalsó kamera",
|
||||
"360_camera": "360°-os kamera",
|
||||
"parking_sensors": "Parkolóérzékelők",
|
||||
"front_parking_sensors": "Első parkolóérzékelők",
|
||||
"rear_parking_sensors": "Hátsó parkolóérzékelők",
|
||||
"side_parking_sensors": "Oldalsó parkolóérzékelők",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 5. Kötelező tartozékok & Egyéb
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
"fire_extinguisher": "Tűzoltó készülék",
|
||||
"first_aid_kit": "Elsősegély csomag",
|
||||
"warning_triangle": "Figyelmeztető háromszög",
|
||||
"wheel_chocks": "Ékek (kerékrögzítő)",
|
||||
"reflective_vest": "Reflexiós mellény",
|
||||
"roof_hatch": "Tetőablak",
|
||||
"roof_ladder": "Tetőlétra",
|
||||
"mudguards": "Sárvédők",
|
||||
"spare_wheel": "Pótkerék",
|
||||
"snow_chains": "Hólánc",
|
||||
"toolbox": "Szerszámos láda",
|
||||
"work_lights": "Munkalámpák",
|
||||
"beacon": "Figyelmeztető lámpa",
|
||||
"reverse_alarm": "Tolatási hangjelző",
|
||||
"side_underrun": "Oldalsó aláfutásgátló",
|
||||
"rear_underrun": "Hátsó aláfutásgátló",
|
||||
},
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# CABIN (Fülke)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
"cabin": {
|
||||
# ═══════════════════════════════════════════════
|
||||
# 1. Klíma & Fűtés
|
||||
# ═══════════════════════════════════════════════
|
||||
"air_conditioner": "Klíma (légkondícionáló)",
|
||||
"automatic_ac": "Automata klíma",
|
||||
"dual_zone_ac": "Kétzónás klíma",
|
||||
"parking_heater": "Parkfűtés (Webasto/Eberspächer)",
|
||||
"auxiliary_heater": "Kiegészítő fűtés",
|
||||
"night_heater": "Éjszakai fűtés (állóhelyi fűtés)",
|
||||
"engine_preheater": "Motor előmelegítő",
|
||||
"roof_vent": "Tetőszellőző",
|
||||
"power_vent": "Elektromos tetőszellőző",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 2. Alvás & Életvitel
|
||||
# ═══════════════════════════════════════════════
|
||||
"sleeper_cabin": "Hálófülke",
|
||||
"single_bunk": "Egyágyas fekhely",
|
||||
"double_bunk": "Kétágyas fekhely",
|
||||
"upper_bunk": "Felső ágy",
|
||||
"lower_bunk": "Alsó ágy",
|
||||
"fridge": "Hűtőszekrény",
|
||||
"freezer": "Fagyasztó",
|
||||
"microwave": "Mikrohullámú sütő",
|
||||
"coffee_machine": "Kávéfőző",
|
||||
"water_heater": "Vízmelegítő",
|
||||
"cabin_table": "Fülke asztal",
|
||||
"curtain_set": "Függönykészlet",
|
||||
"sun_visor": "Napszemüvegtartó / napellenző",
|
||||
"cabin_lighting": "Fülke világítás",
|
||||
"led_cabin_lighting": "LED fülke világítás",
|
||||
"reading_light": "Olvasólámpa",
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 3. Kényelem & Ergonómia
|
||||
# ═══════════════════════════════════════════════
|
||||
"driver_seat_suspension": "Légrugós vezetőülés",
|
||||
"passenger_seat": "Utasülés",
|
||||
"passenger_seat_swivel": "Forgatható utasülés",
|
||||
"seat_heating": "Ülésfűtés",
|
||||
"seat_ventilation": "Ülésszellőzés",
|
||||
"armrest": "Karfák",
|
||||
"adjustable_steering": "Állítható kormány",
|
||||
"electric_windows": "Elektromos ablakok",
|
||||
"central_locking": "Központi zár",
|
||||
"storage_box": "Tároló doboz",
|
||||
"wardrobe": "Gardrób / ruhatároló",
|
||||
"bed_extension": "Ágy kihúzó panel",
|
||||
"privacy_curtain": "Adatvédő függöny",
|
||||
"insulation_package": "Hang- és hőszigetelés csomag",
|
||||
},
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# MULTIMEDIA (Multimédia)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
"multimedia": {
|
||||
"navigation": "Navigáció",
|
||||
"apple_carplay": "Apple CarPlay",
|
||||
"android_auto": "Android Auto",
|
||||
"bluetooth": "Bluetooth",
|
||||
"usb_charging": "USB töltő",
|
||||
"wireless_charging": "Vezeték nélküli töltő",
|
||||
"dab_radio": "DAB+ digitális rádió",
|
||||
"cb_radio": "CB rádió",
|
||||
"satellite_radio": "Műholdas rádió",
|
||||
"digital_tv": "Digitális TV",
|
||||
"dvb_t": "DVB-T (földi digitális TV)",
|
||||
"dvb_s": "DVB-S (műholdas TV)",
|
||||
"premium_sound": "Prémium hangrendszer",
|
||||
"subwoofer": "Mélysugárzó",
|
||||
"rear_entertainment": "Hátsó szórakoztató rendszer",
|
||||
"wifi_hotspot": "WiFi hotspot",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# HELPER FUNCTIONS
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def get_all_feature_keys() -> list[str]:
|
||||
"""Return all possible feature keys across all vehicle types."""
|
||||
keys: list[str] = []
|
||||
for category in CAR_FEATURES.values():
|
||||
keys.extend(category.keys())
|
||||
for category in MOTORCYCLE_FEATURES.values():
|
||||
keys.extend(category.keys())
|
||||
for category in LCV_FEATURES.values():
|
||||
keys.extend(category.keys())
|
||||
for category in HGV_FEATURES.values():
|
||||
keys.extend(category.keys())
|
||||
return list(set(keys))
|
||||
|
||||
|
||||
def get_feature_label(key: str, lang: str = "hu") -> str:
|
||||
"""Look up a feature label by key across all vehicle type dictionaries."""
|
||||
hu_labels: dict[str, dict[str, str]] = {
|
||||
"car": CAR_FEATURES,
|
||||
"motorcycle": MOTORCYCLE_FEATURES,
|
||||
"lcv": LCV_FEATURES,
|
||||
"hgv": HGV_FEATURES,
|
||||
}
|
||||
for vehicle_type, categories in hu_labels.items():
|
||||
for category_key, features in categories.items():
|
||||
if key in features:
|
||||
return features[key]
|
||||
return key
|
||||
|
||||
|
||||
def get_features_by_category(
|
||||
vehicle_type: str, category: str
|
||||
) -> dict[str, str]:
|
||||
"""Return features for a specific vehicle type and category."""
|
||||
mapping: dict[str, dict[str, dict[str, str]]] = {
|
||||
"car": CAR_FEATURES,
|
||||
"motorcycle": MOTORCYCLE_FEATURES,
|
||||
"lcv": LCV_FEATURES,
|
||||
"hgv": HGV_FEATURES,
|
||||
}
|
||||
vehicle_features = mapping.get(vehicle_type, {})
|
||||
return vehicle_features.get(category, {})
|
||||
|
||||
|
||||
def get_motorcycle_feature_label(key: str) -> str:
|
||||
"""Look up a motorcycle feature label by key."""
|
||||
for category in MOTORCYCLE_FEATURES.values():
|
||||
if key in category:
|
||||
return category[key]
|
||||
return key
|
||||
|
||||
|
||||
def get_lcv_feature_label(key: str) -> str:
|
||||
"""Look up an LCV feature label by key."""
|
||||
for category in LCV_FEATURES.values():
|
||||
if key in category:
|
||||
return category[key]
|
||||
return key
|
||||
|
||||
|
||||
def get_hgv_feature_label(key: str) -> str:
|
||||
"""Look up an HGV feature label by key."""
|
||||
for category in HGV_FEATURES.values():
|
||||
if key in category:
|
||||
return category[key]
|
||||
return key
|
||||
@@ -62,6 +62,7 @@ class UserStats(Base):
|
||||
penalty_quota_remaining: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
places_discovered: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
places_validated: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
providers_added_count: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||
banned_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -115,6 +115,22 @@ class Organization(Base):
|
||||
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
|
||||
)
|
||||
|
||||
# --- 🔍 CROWDSOURCED SEARCH FIELDS ---
|
||||
# Provider nicknames / alternative names for matching (e.g. ["MOL", "MOL LUB", "MOL Magyarország"])
|
||||
aliases: Mapped[list] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=list,
|
||||
server_default=text("'[]'::jsonb")
|
||||
)
|
||||
# Crowdsourced evaluation characteristics / tags (e.g. ["gyors", "megbízható", "kedvező ár"])
|
||||
tags: Mapped[list] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=list,
|
||||
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"))
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ class Asset(Base):
|
||||
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())
|
||||
|
||||
# === WARRANTY ===
|
||||
is_under_warranty: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text('false'))
|
||||
warranty_expiry_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# === SALES MODULE ===
|
||||
is_for_sale: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
price: Mapped[Optional[float]] = mapped_column(Numeric(15, 2))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/asset.py
|
||||
from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator, field_validator
|
||||
from typing import Optional, Dict, Any, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
@@ -82,6 +82,10 @@ class AssetResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
# === WARRANTY ===
|
||||
is_under_warranty: bool = Field(default=False, description="Érvényes jótállás")
|
||||
warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma")
|
||||
|
||||
# === SALES MODULE ===
|
||||
is_for_sale: bool = Field(default=False)
|
||||
price: Optional[float] = None
|
||||
@@ -128,6 +132,47 @@ class AssetCreate(BaseModel):
|
||||
return None
|
||||
return v
|
||||
|
||||
# ── Data normalization validators ──
|
||||
@field_validator('brand')
|
||||
@classmethod
|
||||
def normalize_brand(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Brand: UPPERCASE, strip whitespace."""
|
||||
if v is None or not isinstance(v, str):
|
||||
return v
|
||||
return v.strip().upper()
|
||||
|
||||
@field_validator('model')
|
||||
@classmethod
|
||||
def normalize_model(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Model: UPPERCASE, remove ALL spaces."""
|
||||
if v is None or not isinstance(v, str):
|
||||
return v
|
||||
return v.upper().replace(' ', '')
|
||||
|
||||
# ── Model validator: normalize type_designation inside individual_equipment ──
|
||||
@model_validator(mode='after')
|
||||
def normalize_individual_equipment(self):
|
||||
"""Normalize type_designation inside individual_equipment.car_specs.
|
||||
|
||||
SAFE None handling: checks every level before calling .upper().
|
||||
- individual_equipment may be None or empty dict
|
||||
- car_specs may be None or missing key
|
||||
- type_designation may be None, empty string, or a valid string
|
||||
"""
|
||||
if not self.individual_equipment:
|
||||
return self
|
||||
if not isinstance(self.individual_equipment, dict):
|
||||
return self
|
||||
|
||||
car_specs = self.individual_equipment.get('car_specs')
|
||||
if not car_specs or not isinstance(car_specs, dict):
|
||||
return self
|
||||
|
||||
td = car_specs.get('type_designation')
|
||||
if td is not None and isinstance(td, str) and td.strip():
|
||||
car_specs['type_designation'] = td.strip().upper().replace(' ', '')
|
||||
return self
|
||||
|
||||
# ── Root validator: ensure at least one of vin or license_plate is provided ──
|
||||
@root_validator(skip_on_failure=True)
|
||||
def validate_vin_or_plate(cls, values):
|
||||
@@ -166,7 +211,7 @@ class AssetCreate(BaseModel):
|
||||
trim_level: Optional[str] = Field(None, max_length=100, description="Felszereltségi szint")
|
||||
roof_type: Optional[str] = Field(None, max_length=50, description="Tető típus")
|
||||
audio_system_type: Optional[str] = Field(None, max_length=100, description="Hangrendszer")
|
||||
individual_equipment: Dict[str, Any] = Field(default_factory=dict, description="Egyedi felszerelések")
|
||||
individual_equipment: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Egyedi felszerelések")
|
||||
|
||||
# === MILEAGE (Optional) ===
|
||||
current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás")
|
||||
@@ -179,6 +224,10 @@ class AssetCreate(BaseModel):
|
||||
organization_id: Optional[int] = Field(None, description="Szervezet ID (alapértelmezett a felhasználó szervezete)")
|
||||
branch_id: Optional[UUID] = Field(None, description="Garázs (Branch) ID, ahova a járművet rendeljük. Ha nincs megadva, a szervezet központi garázsába kerül.")
|
||||
|
||||
# === WARRANTY ===
|
||||
is_under_warranty: bool = Field(default=False, description="Érvényes jótállás")
|
||||
warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma")
|
||||
|
||||
# === PRIMARY VEHICLE FLAG ===
|
||||
is_primary: bool = Field(default=False, description="Elsődleges jármű (tárolva: individual_equipment JSONB)")
|
||||
|
||||
@@ -224,6 +273,47 @@ class AssetUpdate(BaseModel):
|
||||
return None
|
||||
return v
|
||||
|
||||
# ── Data normalization validators ──
|
||||
@field_validator('brand')
|
||||
@classmethod
|
||||
def normalize_brand(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Brand: UPPERCASE, strip whitespace."""
|
||||
if v is None or not isinstance(v, str):
|
||||
return v
|
||||
return v.strip().upper()
|
||||
|
||||
@field_validator('model')
|
||||
@classmethod
|
||||
def normalize_model(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Model: UPPERCASE, remove ALL spaces."""
|
||||
if v is None or not isinstance(v, str):
|
||||
return v
|
||||
return v.upper().replace(' ', '')
|
||||
|
||||
# ── Model validator: normalize type_designation inside individual_equipment ──
|
||||
@model_validator(mode='after')
|
||||
def normalize_individual_equipment(self):
|
||||
"""Normalize type_designation inside individual_equipment.car_specs.
|
||||
|
||||
SAFE None handling: checks every level before calling .upper().
|
||||
- individual_equipment may be None or empty dict
|
||||
- car_specs may be None or missing key
|
||||
- type_designation may be None, empty string, or a valid string
|
||||
"""
|
||||
if not self.individual_equipment:
|
||||
return self
|
||||
if not isinstance(self.individual_equipment, dict):
|
||||
return self
|
||||
|
||||
car_specs = self.individual_equipment.get('car_specs')
|
||||
if not car_specs or not isinstance(car_specs, dict):
|
||||
return self
|
||||
|
||||
td = car_specs.get('type_designation')
|
||||
if td is not None and isinstance(td, str) and td.strip():
|
||||
car_specs['type_designation'] = td.strip().upper().replace(' ', '')
|
||||
return self
|
||||
|
||||
# ── Model validator: ensure at least one of vin or license_plate is provided ──
|
||||
@model_validator(mode='after')
|
||||
def validate_vin_or_plate(self):
|
||||
@@ -276,8 +366,63 @@ class AssetUpdate(BaseModel):
|
||||
year_of_manufacture: Optional[int] = Field(None, ge=1900, le=2100, description="Gyártási év")
|
||||
first_registration_date: Optional[datetime] = Field(None, description="Első forgalomba helyezés dátuma")
|
||||
|
||||
# === WARRANTY ===
|
||||
is_under_warranty: Optional[bool] = Field(None, description="Érvényes jótállás")
|
||||
warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma")
|
||||
|
||||
# === STATUS ===
|
||||
current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás")
|
||||
is_primary: Optional[bool] = Field(None, description="Elsődleges jármű")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AssetEventCreate(BaseModel):
|
||||
"""Schema for creating a new AssetEvent (Service Book entry)."""
|
||||
event_type: str = Field(..., description="Event type: SERVICE, REPAIR, ACCIDENT, INSPECTION, TIRE_CHANGE, MAINTENANCE, UPGRADE, RECALL")
|
||||
event_date: Optional[datetime] = Field(None, description="Event date (default: now)")
|
||||
odometer_reading: Optional[int] = Field(None, ge=0, description="Odometer reading at event time")
|
||||
description: Optional[str] = Field(None, max_length=2000, description="Event description")
|
||||
cost_id: Optional[UUID] = Field(None, description="Associated cost record ID")
|
||||
cost_amount: Optional[float] = Field(None, gt=0, description="Cost amount in the given currency. If > 0, an AssetCost record is auto-created.")
|
||||
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Currency code (e.g. HUF, EUR). Defaults to HUF on backend.")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class AssetEventResponse(BaseModel):
|
||||
"""Schema for returning an AssetEvent."""
|
||||
id: UUID
|
||||
asset_id: UUID
|
||||
user_id: Optional[int] = None
|
||||
organization_id: Optional[int] = None
|
||||
event_type: str
|
||||
odometer_reading: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
cost_id: Optional[UUID] = None
|
||||
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
|
||||
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
|
||||
event_date: datetime
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def model_validate(cls, obj, **kwargs):
|
||||
"""Override to resolve cost_amount and currency from the related AssetCost."""
|
||||
data = {}
|
||||
if hasattr(obj, '__dict__'):
|
||||
# Copy ORM attributes
|
||||
for field in cls.model_fields:
|
||||
if hasattr(obj, field):
|
||||
data[field] = getattr(obj, field)
|
||||
|
||||
# Resolve cost_amount and currency from the cost relationship
|
||||
cost = getattr(obj, 'cost', None)
|
||||
if cost is not None:
|
||||
if data.get('cost_amount') is None:
|
||||
data['cost_amount'] = float(cost.amount_net) if cost.amount_net is not None else None
|
||||
if data.get('currency') is None:
|
||||
data['currency'] = cost.currency
|
||||
return cls(**data)
|
||||
@@ -60,6 +60,9 @@ class OrganizationUpdate(BaseModel):
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# ── Crowdsourced search fields ──
|
||||
aliases: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class OrganizationResponse(BaseModel):
|
||||
@@ -79,5 +82,7 @@ class OrganizationResponse(BaseModel):
|
||||
visual_settings: Optional[dict] = None
|
||||
notification_settings: Optional[Any] = None
|
||||
external_integration_config: Optional[Any] = None
|
||||
aliases: List[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
112
backend/app/schemas/provider.py
Normal file
112
backend/app/schemas/provider.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Provider (szolgáltató) Pydantic sémák a crowdsourced partnerkeresőhöz.
|
||||
Tartalmazza a keresési, gyors felvételi és szerkesztési sémákat.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
|
||||
- street -> address_street_name, address_street_type, address_house_number
|
||||
- A ProviderSearchResult is tartalmazza az atomizált címmezőket.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class ExpertiseCategoryOut(BaseModel):
|
||||
"""Expertise kategória kimeneti sémája a frontend dropdown számára."""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
category: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProviderSearchResult(BaseModel):
|
||||
"""Egy szolgáltató keresési találatának adatai.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- address_street_name, address_street_type, address_house_number
|
||||
- A frontend ezeket intelligensen fűzi össze megjelenítéskor.
|
||||
"""
|
||||
id: int
|
||||
name: str
|
||||
category: Optional[str] = None
|
||||
specialization: List[str] = Field(default_factory=list)
|
||||
city: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
source: str = Field(..., description="Forrás: verified_org | staged_data | crowd_added")
|
||||
is_verified: bool = False
|
||||
rating: Optional[float] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProviderSearchResponse(BaseModel):
|
||||
"""Keresési eredmények lapozással."""
|
||||
results: List[ProviderSearchResult] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
per_page: int = 20
|
||||
|
||||
|
||||
class ProviderQuickAddIn(BaseModel):
|
||||
"""Gyors szolgáltató felvétel bemeneti adatai.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
category_id: int = Field(..., ge=1, description="Kategória ID (marketplace.expertise_tags.id)")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
|
||||
tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)")
|
||||
|
||||
|
||||
class ProviderQuickAddResponse(BaseModel):
|
||||
"""Gyors szolgáltató felvétel válasza."""
|
||||
id: int
|
||||
name: str
|
||||
status: str = "pending_verification"
|
||||
message: str = "Szolgáltató sikeresen rögzítve. Ellenőrzés alatt."
|
||||
earned_points: int = 0
|
||||
|
||||
|
||||
class ProviderUpdateIn(BaseModel):
|
||||
"""Szolgáltató adatainak szerkesztése (PUT /providers/{id}).
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
- Tartalmazza a contact_phone, contact_email, website és tags mezőket is.
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
|
||||
tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)")
|
||||
|
||||
|
||||
class ProviderUpdateResponse(BaseModel):
|
||||
"""Szolgáltató szerkesztés válasza."""
|
||||
id: int
|
||||
name: str
|
||||
message: str = "Szolgáltató adatai sikeresen frissítve."
|
||||
242
backend/app/scripts/heal_user_data.py
Normal file
242
backend/app/scripts/heal_user_data.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
🤖 Data Healing Script — Wallet & Referral Code Repair
|
||||
|
||||
Detects and fixes missing Wallet and InvitationCode (referral_code) records
|
||||
for existing User and Organization entities.
|
||||
|
||||
Usage:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/heal_user_data.py
|
||||
|
||||
Logs:
|
||||
- Prints a summary of healed records to stdout
|
||||
- Writes detailed log to logs/heal_user_data_{timestamp}.log
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
# Ensure the backend app is on the path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.identity import User, Wallet
|
||||
from app.models.marketplace.organization import Organization
|
||||
|
||||
# ── Logging setup ──────────────────────────────────────────────────────────
|
||||
LOG_DIR = "/app/backend/logs"
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
log_filename = f"heal_user_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||
log_path = os.path.join(LOG_DIR, log_filename)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(log_path),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger("heal-user-data")
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_referral_code() -> str:
|
||||
"""Generate a short, unique referral code (8 chars, uppercase)."""
|
||||
import secrets
|
||||
import string
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(8))
|
||||
|
||||
|
||||
# ── Main healing logic ─────────────────────────────────────────────────────
|
||||
|
||||
async def heal_users(db: AsyncSession) -> Dict[str, int]:
|
||||
"""
|
||||
Find all Users without a Wallet and create one (0 balance).
|
||||
Find all Users without a referral_code and generate one.
|
||||
"""
|
||||
stats = {"wallet_created": 0, "referral_code_generated": 0}
|
||||
|
||||
# 1. Fetch all users
|
||||
result = await db.execute(select(User))
|
||||
users = result.scalars().all()
|
||||
logger.info(f"Found {len(users)} total User records.")
|
||||
|
||||
for user in users:
|
||||
# ── Wallet check ──
|
||||
wallet_stmt = select(Wallet).where(Wallet.user_id == user.id)
|
||||
wallet_result = await db.execute(wallet_stmt)
|
||||
existing_wallet = wallet_result.scalar_one_or_none()
|
||||
|
||||
if existing_wallet is None:
|
||||
new_wallet = Wallet(
|
||||
user_id=user.id,
|
||||
earned_credits=0,
|
||||
purchased_credits=0,
|
||||
service_coins=0,
|
||||
currency="HUF",
|
||||
)
|
||||
db.add(new_wallet)
|
||||
stats["wallet_created"] += 1
|
||||
logger.info(f" [Wallet] Created for User ID={user.id} ({user.email})")
|
||||
else:
|
||||
logger.debug(f" [Wallet] Already exists for User ID={user.id}")
|
||||
|
||||
# ── Referral code check ──
|
||||
if not user.referral_code:
|
||||
code = generate_referral_code()
|
||||
# Ensure uniqueness
|
||||
while True:
|
||||
dup_check = await db.execute(
|
||||
select(User).where(User.referral_code == code)
|
||||
)
|
||||
if dup_check.scalar_one_or_none() is None:
|
||||
break
|
||||
code = generate_referral_code()
|
||||
|
||||
user.referral_code = code
|
||||
stats["referral_code_generated"] += 1
|
||||
logger.info(
|
||||
f" [Referral] Generated code '{code}' for User ID={user.id} ({user.email})"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f" [Referral] Already has code '{user.referral_code}' for User ID={user.id}"
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def heal_organizations(db: AsyncSession) -> Dict[str, int]:
|
||||
"""
|
||||
Check Organizations for wallet coverage.
|
||||
|
||||
NOTE: The `wallets` table has:
|
||||
- user_id: NOT NULL + UNIQUE constraint
|
||||
- organization_id: nullable
|
||||
|
||||
This means each user can have exactly one wallet, and organization
|
||||
wallets share the user's wallet via organization_id. Since user wallets
|
||||
are already created in heal_users(), org wallets are inherently covered.
|
||||
|
||||
We only log existing org wallet links for audit purposes.
|
||||
"""
|
||||
stats = {"org_wallet_created": 0, "org_wallet_skipped": 0}
|
||||
|
||||
result = await db.execute(select(Organization))
|
||||
orgs = result.scalars().all()
|
||||
logger.info(f"Found {len(orgs)} total Organization records.")
|
||||
|
||||
for org in orgs:
|
||||
# Check if an org wallet already exists (linked via organization_id)
|
||||
wallet_stmt = select(Wallet).where(Wallet.organization_id == org.id)
|
||||
wallet_result = await db.execute(wallet_stmt)
|
||||
existing_wallet = wallet_result.scalar_one_or_none()
|
||||
|
||||
if existing_wallet is None:
|
||||
# The wallets.user_id is NOT NULL + UNIQUE, so we cannot create
|
||||
# a separate wallet for an org. The org's owner already has a
|
||||
# personal wallet from heal_users(). We skip org wallet creation
|
||||
# and log the situation.
|
||||
owner_id = getattr(org, 'owner_id', None)
|
||||
if owner_id:
|
||||
# Check if owner has a wallet we could link
|
||||
owner_wallet = await db.execute(
|
||||
select(Wallet).where(Wallet.user_id == owner_id)
|
||||
)
|
||||
owner_wallet = owner_wallet.scalar_one_or_none()
|
||||
if owner_wallet:
|
||||
# Link the existing wallet to this org
|
||||
owner_wallet.organization_id = org.id
|
||||
stats["org_wallet_created"] += 1
|
||||
logger.info(
|
||||
f" [OrgWallet] Linked existing wallet (user_id={owner_id}) "
|
||||
f"to Organization ID={org.id} ({org.name})"
|
||||
)
|
||||
else:
|
||||
stats["org_wallet_skipped"] += 1
|
||||
logger.warning(
|
||||
f" [OrgWallet] SKIPPED for Organization ID={org.id} ({org.name}) — "
|
||||
f"owner (user_id={owner_id}) has no wallet yet."
|
||||
)
|
||||
else:
|
||||
stats["org_wallet_skipped"] += 1
|
||||
logger.warning(
|
||||
f" [OrgWallet] SKIPPED for Organization ID={org.id} ({org.name}) — "
|
||||
f"no owner_id found."
|
||||
)
|
||||
else:
|
||||
logger.debug(f" [OrgWallet] Already linked for Organization ID={org.id}")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point."""
|
||||
logger.info("=" * 60)
|
||||
logger.info(" DATA HEALING SCRIPT — Wallet & Referral Code Repair")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Build async engine from the same DATABASE_URL
|
||||
db_url = settings.DATABASE_URL
|
||||
# If the URL starts with postgresql://, convert to postgresql+asyncpg://
|
||||
if db_url.startswith("postgresql://") and "+asyncpg" not in db_url:
|
||||
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(db_url, echo=False, pool_pre_ping=True)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
try:
|
||||
async with db.begin():
|
||||
user_stats = await heal_users(db)
|
||||
org_stats = await heal_organizations(db)
|
||||
|
||||
total_healed = (
|
||||
user_stats["wallet_created"]
|
||||
+ user_stats["referral_code_generated"]
|
||||
+ org_stats["org_wallet_created"]
|
||||
)
|
||||
|
||||
logger.info("─" * 60)
|
||||
logger.info(" ✅ HEALING COMPLETE — Summary")
|
||||
logger.info(f" User wallets created: {user_stats['wallet_created']}")
|
||||
logger.info(f" Referral codes generated: {user_stats['referral_code_generated']}")
|
||||
logger.info(f" Organization wallets created: {org_stats['org_wallet_created']}")
|
||||
logger.info(f" Organization wallets skipped: {org_stats['org_wallet_skipped']}")
|
||||
logger.info(f" ─────────────────────────────────")
|
||||
logger.info(f" TOTAL records healed: {total_healed}")
|
||||
logger.info("─" * 60)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" ✅ DATA HEALING COMPLETE")
|
||||
print(f" Log file: {log_path}")
|
||||
print(f"{'='*60}")
|
||||
print(f" User wallets created: {user_stats['wallet_created']}")
|
||||
print(f" Referral codes generated: {user_stats['referral_code_generated']}")
|
||||
print(f" Organization wallets created: {org_stats['org_wallet_created']}")
|
||||
print(f" Organization wallets skipped: {org_stats['org_wallet_skipped']}")
|
||||
print(f" ─────────────────────────────────")
|
||||
print(f" TOTAL records healed: {total_healed}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Healing failed: {e}", exc_info=True)
|
||||
print(f"\n❌ ERROR: Healing failed — {e}\n")
|
||||
raise
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
156
backend/app/scripts/seed_expertise_tags.py
Normal file
156
backend/app/scripts/seed_expertise_tags.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Seed script: Feltölti a marketplace.expertise_tags táblát alap kategóriákkal.
|
||||
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
|
||||
|
||||
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_expertise_tags.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy import select, func
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ExpertiseTag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_CATEGORIES = [
|
||||
{
|
||||
"key": "auto_szerelo",
|
||||
"name_hu": "Autószerelő",
|
||||
"name_en": "Car Mechanic",
|
||||
"category": "vehicle_service",
|
||||
"search_keywords": ["szerelő", "autószerelő", "mechanic", "garage", "javítás"],
|
||||
"description": "Személy- és haszongépjárművek mechanikai javítása, karbantartása",
|
||||
},
|
||||
{
|
||||
"key": "motor_szerelo",
|
||||
"name_hu": "Motorkerékpár szerelő",
|
||||
"name_en": "Motorcycle Mechanic",
|
||||
"category": "vehicle_service",
|
||||
"search_keywords": ["motor", "motorszerelő", "motorcycle", "robogó"],
|
||||
"description": "Motorkerékpárok, robogók javítása és karbantartása",
|
||||
},
|
||||
{
|
||||
"key": "gumiszerviz",
|
||||
"name_hu": "Gumiszerviz",
|
||||
"name_en": "Tire Service",
|
||||
"category": "vehicle_service",
|
||||
"search_keywords": ["gumi", "gumis", "tire", "tyre", "abroncs", "kerék"],
|
||||
"description": "Gumiabroncsok cseréje, javítása, téli/nyári gumik tárolása",
|
||||
},
|
||||
{
|
||||
"key": "karosszerialakatos",
|
||||
"name_hu": "Karosszérialakatos",
|
||||
"name_en": "Body Shop",
|
||||
"category": "body_paint",
|
||||
"search_keywords": ["karosszéria", "lakatos", "bodyshop", "karosszerialakatos", "kasztni"],
|
||||
"description": "Karosszéria javítás, lakatolás, kasztni javítás",
|
||||
},
|
||||
{
|
||||
"key": "fényező",
|
||||
"name_hu": "Fényező",
|
||||
"name_en": "Painter",
|
||||
"category": "body_paint",
|
||||
"search_keywords": ["fényező", "fényezés", "painter", "spray", "lakkozás"],
|
||||
"description": "Gépjármű fényezés, lakkozás, színjavítás",
|
||||
},
|
||||
{
|
||||
"key": "autómentő",
|
||||
"name_hu": "Autómentő / Motormentő",
|
||||
"name_en": "Tow Truck / Roadside Assistance",
|
||||
"category": "roadside",
|
||||
"search_keywords": ["mentő", "autómentő", "tow", "roadside", "assistance", "segély"],
|
||||
"description": "Útsegély, autómentés, motormentés, kiszállás",
|
||||
},
|
||||
{
|
||||
"key": "benzinkút",
|
||||
"name_hu": "Benzinkút",
|
||||
"name_en": "Gas Station",
|
||||
"category": "fuel",
|
||||
"search_keywords": ["benzin", "kút", "gas station", "fuel", "dízel", "töltő"],
|
||||
"description": "Üzemanyag töltőállomások, benzinkutak",
|
||||
},
|
||||
{
|
||||
"key": "alkatrész_kereskedés",
|
||||
"name_hu": "Alkatrész kereskedés",
|
||||
"name_en": "Parts Store",
|
||||
"category": "parts",
|
||||
"search_keywords": ["alkatrész", "parts", "spares", "autóalkatrész"],
|
||||
"description": "Gépjármű alkatrészek kereskedelme",
|
||||
},
|
||||
{
|
||||
"key": "vizsgaállomás",
|
||||
"name_hu": "Vizsgaállomás",
|
||||
"name_en": "Inspection Station",
|
||||
"category": "inspection",
|
||||
"search_keywords": ["vizsga", "műszaki", "inspection", "MOT", "vizsgáztatás"],
|
||||
"description": "Műszaki vizsgáztatás, emisszió mérés",
|
||||
},
|
||||
{
|
||||
"key": "autókozmetika",
|
||||
"name_hu": "Autókozmetika",
|
||||
"name_en": "Car Detailing",
|
||||
"category": "detailing",
|
||||
"search_keywords": ["kozmetika", "detailing", "takarítás", "mosás", "polírozás"],
|
||||
"description": "Autókozmetika, részletes takarítás, polírozás, kerámia bevonat",
|
||||
},
|
||||
{
|
||||
"key": "egyéb",
|
||||
"name_hu": "Egyéb szolgáltató",
|
||||
"name_en": "Other Service",
|
||||
"category": "other",
|
||||
"search_keywords": ["egyéb", "other", "szolgáltató", "service"],
|
||||
"description": "Egyéb gépjárművel kapcsolatos szolgáltatás",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_expertise_tags():
|
||||
"""Feltölti az expertise_tags táblát az alap kategóriákkal."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
# Ellenőrizzük, van-e már adat
|
||||
stmt = select(func.count(ExpertiseTag.id))
|
||||
result = await db.execute(stmt)
|
||||
count = result.scalar()
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"Az expertise_tags tábla már tartalmaz {count} rekordot. Feltöltés kihagyva.")
|
||||
print(f"✅ Az expertise_tags tábla már tartalmaz {count} rekordot. Nincs szükség seed-elésre.")
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
for cat in BASE_CATEGORIES:
|
||||
tag = ExpertiseTag(
|
||||
key=cat["key"],
|
||||
name_hu=cat["name_hu"],
|
||||
name_en=cat["name_en"],
|
||||
category=cat["category"],
|
||||
search_keywords=cat["search_keywords"],
|
||||
description=cat["description"],
|
||||
is_official=True,
|
||||
discovery_points=10,
|
||||
usage_count=0,
|
||||
)
|
||||
db.add(tag)
|
||||
inserted += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Sikeresen beszúrva {inserted} kategória az expertise_tags táblába.")
|
||||
print(f"✅ Sikeresen beszúrva {inserted} kategória az expertise_tags táblába.")
|
||||
|
||||
# Ellenőrzés
|
||||
result = await db.execute(select(func.count(ExpertiseTag.id)))
|
||||
final_count = result.scalar()
|
||||
print(f"📊 Összes rekord az expertise_tags táblában: {final_count}")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Hiba a seed-elés során: {e}")
|
||||
print(f"❌ Hiba: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
asyncio.run(seed_expertise_tags())
|
||||
118
backend/app/scripts/seed_gamification_rules.py
Normal file
118
backend/app/scripts/seed_gamification_rules.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Gamification Point Rules Seed Script
|
||||
=====================================
|
||||
Cél: Dinamikus pontszabályok feltöltése a gamification.point_rules táblába.
|
||||
A pontértékek NINCSENEK beégetve a kódba, hanem az adatbázisból
|
||||
(point_rules tábla) olvassuk ki őket futásidőben.
|
||||
|
||||
A Vezető Tervező utasítása szerint:
|
||||
- TILOS hardcoded pontszámokat használni a service rétegben!
|
||||
- Minden pontértéket a point_rules táblából kell lekérdezni action_key alapján.
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_gamification_rules.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Projekt gyökér hozzáadása a Python path-hoz
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.models.gamification.gamification import PointRule
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("Seed-Gamification-Rules")
|
||||
|
||||
# A seed-elendő pontszabályok definíciói
|
||||
# FIGYELEM: Ezek a definíciók csak a seed script számára vannak itt.
|
||||
# A service rétegben a pontokat az adatbázisból (point_rules tábla) kell kiolvasni!
|
||||
SEED_RULES = [
|
||||
{
|
||||
"action_key": "ADD_NEW_PROVIDER",
|
||||
"points": 500,
|
||||
"description": "Új szolgáltató rögzítése a rendszerbe",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "USE_UNVERIFIED_PROVIDER",
|
||||
"points": 200,
|
||||
"description": "Szervizesemény/költség rögzítése olyan szolgáltatónál, aminek még nincs 5 megerősítése",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "RATE_PROVIDER",
|
||||
"points": 250,
|
||||
"description": "Szolgáltató értékelése (tagekkel)",
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_point_rules():
|
||||
"""
|
||||
Feltölti a gamification.point_rules táblát a fenti szabályokkal,
|
||||
de csak azokat a rekordokat szúrja be, amelyek még nem léteznek
|
||||
(action_key alapján UPSERT helyett INSERT WHERE NOT EXISTS).
|
||||
"""
|
||||
# Adatbázis kapcsolat létrehozása
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
inserted_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for rule_data in SEED_RULES:
|
||||
# Ellenőrizzük, hogy létezik-e már ez a szabály
|
||||
stmt = select(PointRule).where(
|
||||
PointRule.action_key == rule_data["action_key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(
|
||||
f"⏭️ Szabály már létezik: {rule_data['action_key']} "
|
||||
f"(pont: {existing.points}, ID: {existing.id})"
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Új szabály beszúrása
|
||||
new_rule = PointRule(
|
||||
action_key=rule_data["action_key"],
|
||||
points=rule_data["points"],
|
||||
description=rule_data["description"],
|
||||
is_active=rule_data["is_active"],
|
||||
)
|
||||
db.add(new_rule)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"✅ Szabály létrehozva: {rule_data['action_key']} "
|
||||
f"(pont: {rule_data['points']}, ID: {new_rule.id})"
|
||||
)
|
||||
inserted_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"\n📊 Összegzés: {inserted_count} új szabály beszúrva, "
|
||||
f"{skipped_count} már létezett."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_point_rules())
|
||||
@@ -287,7 +287,7 @@ class AssetService:
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Asset Creation Error: {e}")
|
||||
logger.error(f"Asset Creation Error: {e}", exc_info=True)
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
@@ -360,8 +360,11 @@ class AssetService:
|
||||
# --- CATALOG METHODS ---
|
||||
@staticmethod
|
||||
async def get_makes(db: AsyncSession, vehicle_class: Optional[str] = None) -> List[str]:
|
||||
"""Get all distinct makes from vehicle model definitions, optionally filtered by vehicle_class."""
|
||||
stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
|
||||
"""Get all distinct makes from vehicle model definitions, optionally filtered by vehicle_class.
|
||||
|
||||
Uses func.upper() to normalize brand casing and deduplicate case-insensitively.
|
||||
"""
|
||||
stmt = select(distinct(func.upper(VehicleModelDefinition.make))).order_by(func.upper(VehicleModelDefinition.make))
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
|
||||
result = await db.execute(stmt)
|
||||
@@ -417,10 +420,11 @@ class AssetService:
|
||||
async def get_catalog_brands(db: AsyncSession, vehicle_class: Optional[str] = None, query: Optional[str] = None) -> List[str]:
|
||||
"""Get all distinct brands (makes) from vehicle_model_definitions, with optional class filter and search query.
|
||||
|
||||
Uses func.upper() to normalize brand casing and deduplicate case-insensitively.
|
||||
Fuzzy matching: removes spaces and lowercases both the query and stored values
|
||||
so that 'BMW' matches 'bmw' and 'CB 1000' matches 'CB1000'.
|
||||
"""
|
||||
stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
|
||||
stmt = select(distinct(func.upper(VehicleModelDefinition.make))).order_by(func.upper(VehicleModelDefinition.make))
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
|
||||
if query:
|
||||
@@ -487,8 +491,11 @@ class AssetService:
|
||||
@staticmethod
|
||||
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
|
||||
"""
|
||||
Get the vehicle limit for a user, checking both user-specific AND organization limits.
|
||||
Returns the HIGHER value of the two as per requirements.
|
||||
Get the vehicle limit for a user, checking:
|
||||
1. Config-based limits (user role, subscription plan, org-specific)
|
||||
2. Subscription tier JSONB rules['allowances']['max_vehicles']
|
||||
|
||||
Returns the HIGHEST value among all applicable limits.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
@@ -496,9 +503,10 @@ class AssetService:
|
||||
org_id: Organization ID
|
||||
|
||||
Returns:
|
||||
Maximum allowed vehicles (higher of user limit and organization limit)
|
||||
Maximum allowed vehicles (highest of all applicable limits)
|
||||
"""
|
||||
from app.models.identity import User
|
||||
from app.models.core_logic import UserSubscription, SubscriptionTier
|
||||
from app.services.config_service import config
|
||||
|
||||
try:
|
||||
@@ -506,46 +514,74 @@ class AssetService:
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one()
|
||||
|
||||
# Get global vehicle limits configuration
|
||||
# ── 1. CONFIG-BASED LIMIT ──
|
||||
limits = await config.get_setting(db, "VEHICLE_LIMIT")
|
||||
if limits is None:
|
||||
logger.error(f"VEHICLE_LIMIT configuration not found in database for user {user_id}")
|
||||
# Fallback to very high limit instead of restricting users
|
||||
limits = {"admin": 9999, "superadmin": 9999, "user": 100, "free": 100, "premium": 100, "vip": 100, "service_pro": 100}
|
||||
|
||||
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
subscription_plan = user.subscription_plan or "free"
|
||||
|
||||
# Get user-specific limit (based on role or subscription plan)
|
||||
user_limit = limits.get(user_role)
|
||||
if user_limit is None:
|
||||
# FIX: If role not found (e.g. "user" not in dict), try subscription plan
|
||||
user_limit = limits.get(subscription_plan.lower())
|
||||
if user_limit is None:
|
||||
# FIX: Ultimate fallback - safe default for free/basic users
|
||||
user_limit = limits.get("free", 1)
|
||||
config_limit = limits.get(user_role)
|
||||
if config_limit is None:
|
||||
config_limit = limits.get(subscription_plan.lower())
|
||||
if config_limit is None:
|
||||
config_limit = limits.get("free", 1)
|
||||
|
||||
# Get organization-specific limit (if configured)
|
||||
# ── 2. ORGANIZATION-SPECIFIC LIMIT ──
|
||||
org_limit = None
|
||||
try:
|
||||
org_limits = await config.get_setting(db, "VEHICLE_LIMIT", org_id=org_id)
|
||||
if org_limits and isinstance(org_limits, dict):
|
||||
# Organization might have different limit structure
|
||||
# Try to get limit for user's role or use a default org limit
|
||||
org_limit = org_limits.get(user_role) or org_limits.get(subscription_plan.lower())
|
||||
if org_limit is None and "default" in org_limits:
|
||||
org_limit = org_limits["default"]
|
||||
except Exception as e:
|
||||
logger.debug(f"No organization-specific VEHICLE_LIMIT found for org {org_id}: {e}")
|
||||
org_limit = None
|
||||
|
||||
# Log the calculated limit for debugging
|
||||
final_limit = user_limit
|
||||
# ── 3. SUBSCRIPTION TIER JSONB LIMIT ──
|
||||
# Query the user's active subscription and read rules['allowances']['max_vehicles']
|
||||
subscription_limit = None
|
||||
try:
|
||||
sub_stmt = (
|
||||
select(SubscriptionTier.rules)
|
||||
.select_from(UserSubscription)
|
||||
.join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id)
|
||||
.where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
sub_result = await db.execute(sub_stmt)
|
||||
tier_rules = sub_result.scalar_one_or_none()
|
||||
|
||||
if tier_rules and isinstance(tier_rules, dict):
|
||||
allowances = tier_rules.get('allowances', {})
|
||||
if isinstance(allowances, dict):
|
||||
max_vehicles = allowances.get('max_vehicles')
|
||||
if max_vehicles is not None and isinstance(max_vehicles, (int, float)):
|
||||
subscription_limit = int(max_vehicles)
|
||||
logger.info(
|
||||
f"Subscription tier limit for user {user_id}: "
|
||||
f"max_vehicles={subscription_limit} (from rules={tier_rules})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read subscription tier limit for user {user_id}: {e}")
|
||||
|
||||
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
|
||||
final_limit = config_limit
|
||||
if org_limit is not None:
|
||||
final_limit = max(user_limit, org_limit)
|
||||
logger.info(f"Calculated limit for user {user_id} (role: {user_role}, plan: {subscription_plan}): user_limit={user_limit}, org_limit={org_limit}, final={final_limit}")
|
||||
else:
|
||||
logger.info(f"Calculated limit for user {user_id} (role: {user_role}, plan: {subscription_plan}): user_limit={user_limit}, org_limit=None, final={final_limit}")
|
||||
final_limit = max(final_limit, org_limit)
|
||||
if subscription_limit is not None:
|
||||
final_limit = max(final_limit, subscription_limit)
|
||||
|
||||
logger.info(
|
||||
f"Vehicle limit for user {user_id} (role={user_role}, plan={subscription_plan}): "
|
||||
f"config={config_limit}, org={org_limit}, subscription={subscription_limit}, "
|
||||
f"final={final_limit}"
|
||||
)
|
||||
|
||||
return final_limit
|
||||
|
||||
|
||||
650
backend/app/services/provider_service.py
Normal file
650
backend/app/services/provider_service.py
Normal file
@@ -0,0 +1,650 @@
|
||||
"""
|
||||
Provider Service – Szolgáltató keresés, gyors felvétel és szerkesztés logikája.
|
||||
|
||||
Gamification Integration (2026-06-16):
|
||||
=======================================
|
||||
A Vezető Tervező utasítása szerint a crowdsourcing funkciókba
|
||||
dinamikus Gamification horgok kerültek beépítésre.
|
||||
|
||||
ALAPELV: A pontértékek SOHA nincsenek beégetve (hardcoded) a kódba!
|
||||
Minden pontszámot a gamification.point_rules táblából olvasunk ki
|
||||
action_key alapján futásidőben.
|
||||
|
||||
Jelenlegi horgok:
|
||||
- ADD_NEW_PROVIDER: 500 pont (adatbázisból) + providers_added_count növelés
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
=====================================================
|
||||
- quick_add_provider: data.street -> data.address_street_name, address_street_type, address_house_number
|
||||
- update_provider: data.street -> data.address_street_name, address_street_type, address_house_number
|
||||
- search_providers: visszaadja az address_street_name, address_street_type, address_house_number mezőket
|
||||
- A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
|
||||
from app.models.marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging
|
||||
from app.models.identity.social import ServiceProvider
|
||||
from app.models.gamification.gamification import PointRule, UserStats
|
||||
from app.schemas.provider import (
|
||||
ProviderSearchResult,
|
||||
ProviderSearchResponse,
|
||||
ProviderQuickAddIn,
|
||||
ProviderQuickAddResponse,
|
||||
ProviderUpdateIn,
|
||||
ProviderUpdateResponse,
|
||||
)
|
||||
from app.services.gamification_service import gamification_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _award_provider_points(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
action_key: str,
|
||||
) -> int:
|
||||
"""
|
||||
Dinamikus pontjóváírás a gamification.point_rules tábla alapján.
|
||||
|
||||
Ez a függvény az adatbázisból olvassa ki a pontértéket az action_key
|
||||
alapján, így a pontok NINCSENEK beégetve a kódba. Az admin felületről
|
||||
bármikor módosíthatók a point_rules táblában.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
user_id: A felhasználó ID-ja
|
||||
action_key: A pontszabály azonosítója (pl. 'ADD_NEW_PROVIDER')
|
||||
|
||||
Returns:
|
||||
int: A jóváírt pontok száma (0 ha a szabály nem található vagy inaktív)
|
||||
"""
|
||||
# 1. Pontszabály lekérdezése az adatbázisból (dinamikus!)
|
||||
rule_stmt = select(PointRule).where(
|
||||
PointRule.action_key == action_key,
|
||||
PointRule.is_active == True,
|
||||
)
|
||||
rule_result = await db.execute(rule_stmt)
|
||||
rule = rule_result.scalar_one_or_none()
|
||||
|
||||
if not rule:
|
||||
logger.warning(
|
||||
f"Pontszabály '{action_key}' nem található vagy inaktív. "
|
||||
f"Pontjóváírás kihagyva user_id={user_id}."
|
||||
)
|
||||
return 0
|
||||
|
||||
points_to_award = rule.points
|
||||
logger.info(
|
||||
f"Dinamikus pont lekérés: action_key='{action_key}', "
|
||||
f"points={points_to_award} (forrás: gamification.point_rules tábla)"
|
||||
)
|
||||
|
||||
# 2. Pontok jóváírása a Gamification Service-en keresztül
|
||||
# A GamificationService.award_points() kezeli a szorzókat, szintlépést,
|
||||
# büntetés ledolgozást és a naplózást (PointsLedger).
|
||||
await gamification_service.award_points(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount=points_to_award,
|
||||
reason=f"ADD_NEW_PROVIDER: +{points_to_award} pont új szolgáltató rögzítéséért",
|
||||
commit=False, # A hívó (quick_add_provider) kezeli a commit-ot
|
||||
)
|
||||
|
||||
# 3. Statisztika növelése (providers_added_count)
|
||||
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
|
||||
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
|
||||
|
||||
if stats:
|
||||
stats.providers_added_count = UserStats.providers_added_count + 1
|
||||
logger.info(
|
||||
f"Statisztika frissítve: user_id={user_id}, "
|
||||
f"providers_added_count={stats.providers_added_count}"
|
||||
)
|
||||
else:
|
||||
# Ha még nincs UserStats rekordja, létrehozzuk
|
||||
stats = UserStats(
|
||||
user_id=user_id,
|
||||
total_xp=points_to_award,
|
||||
current_level=1,
|
||||
providers_added_count=1,
|
||||
)
|
||||
db.add(stats)
|
||||
logger.info(
|
||||
f"Új UserStats rekord létrehozva: user_id={user_id}, "
|
||||
f"providers_added_count=1"
|
||||
)
|
||||
|
||||
return points_to_award
|
||||
|
||||
|
||||
async def search_providers(
|
||||
db: AsyncSession,
|
||||
q: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> ProviderSearchResponse:
|
||||
"""
|
||||
Egyesített szolgáltató keresés három forrásból:
|
||||
1. fleet.organizations (verified orgs with org_type='service_provider')
|
||||
2. marketplace.service_staging (robot által gyűjtött adatok)
|
||||
3. marketplace.service_providers (crowdsourced adatok)
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Az org lekérdezés most már visszaadja
|
||||
az address_street_name, address_street_type, address_house_number mezőket is.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
q: Keresőszó (tokenizálva, AND kapcsolat) — opcionális
|
||||
category: Kategória szűrő
|
||||
city: Város szűrő — AND kapcsolat a q-val
|
||||
limit: Lapozási limit
|
||||
offset: Lapozási offset
|
||||
|
||||
Returns:
|
||||
ProviderSearchResponse lapozott találatokkal
|
||||
"""
|
||||
results = []
|
||||
total = 0
|
||||
|
||||
# Tokenizáljuk a q paramétert: szóközök mentén feldaraboljuk
|
||||
q_tokens: list[str] = []
|
||||
if q:
|
||||
q_tokens = [token.strip() for token in q.split() if token.strip()]
|
||||
|
||||
# --- 1. LEKÉRDEZÉS: Szervezetek (fleet.organizations) ---
|
||||
org_conditions = [
|
||||
Organization.org_type == OrgType.service_provider,
|
||||
Organization.is_deleted == False,
|
||||
]
|
||||
if q_tokens:
|
||||
token_conditions = []
|
||||
for token in q_tokens:
|
||||
token_conditions.append(
|
||||
or_(
|
||||
Organization.name.ilike(f"%{token}%"),
|
||||
cast(Organization.aliases, Text).ilike(f"%{token}%"),
|
||||
cast(Organization.tags, Text).ilike(f"%{token}%"),
|
||||
)
|
||||
)
|
||||
org_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
org_conditions.append(
|
||||
or_(
|
||||
Organization.address_city.ilike(f"%{city}%"),
|
||||
Organization.address_zip.ilike(f"%{city}%"),
|
||||
)
|
||||
)
|
||||
|
||||
# P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált
|
||||
# címmezőket (address_street_name, address_street_type, address_house_number).
|
||||
# Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán
|
||||
# ne duplikálódjon a városnév (pl. "Dunakeszi, Dunakeszi").
|
||||
org_stmt = select(
|
||||
Organization.id.label("id"),
|
||||
Organization.name.label("name"),
|
||||
Organization.address_city.label("city"),
|
||||
func.concat(
|
||||
Organization.address_zip,
|
||||
literal(' '),
|
||||
Organization.address_city,
|
||||
literal(', '),
|
||||
Organization.address_street_name,
|
||||
literal(' '),
|
||||
Organization.address_street_type,
|
||||
literal(' '),
|
||||
Organization.address_house_number,
|
||||
).label("address"),
|
||||
Organization.address_zip.label("address_zip"),
|
||||
Organization.address_street_name.label("address_street_name"),
|
||||
Organization.address_street_type.label("address_street_type"),
|
||||
Organization.address_house_number.label("address_house_number"),
|
||||
Organization.is_verified.label("is_verified"),
|
||||
ServiceProfile.contact_phone.label("contact_phone"),
|
||||
ServiceProfile.contact_email.label("contact_email"),
|
||||
ServiceProfile.website.label("website"),
|
||||
ServiceProfile.specialization_tags.label("specialization_tags"),
|
||||
case(
|
||||
(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")),
|
||||
else_=literal("verified_org"),
|
||||
).label("source"),
|
||||
).outerjoin(
|
||||
ServiceProfile,
|
||||
ServiceProfile.organization_id == Organization.id,
|
||||
).where(*org_conditions)
|
||||
|
||||
# --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) ---
|
||||
staging_conditions = []
|
||||
if q_tokens:
|
||||
token_conditions = []
|
||||
for token in q_tokens:
|
||||
token_conditions.append(
|
||||
ServiceStaging.name.ilike(f"%{token}%")
|
||||
)
|
||||
staging_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
staging_conditions.append(
|
||||
or_(
|
||||
ServiceStaging.city.ilike(f"%{city}%"),
|
||||
ServiceStaging.postal_code.ilike(f"%{city}%"),
|
||||
)
|
||||
)
|
||||
|
||||
staging_stmt = select(
|
||||
ServiceStaging.id.label("id"),
|
||||
ServiceStaging.name.label("name"),
|
||||
ServiceStaging.city.label("city"),
|
||||
ServiceStaging.full_address.label("address"),
|
||||
literal(None).label("address_zip"),
|
||||
literal(None).label("address_street_name"),
|
||||
literal(None).label("address_street_type"),
|
||||
literal(None).label("address_house_number"),
|
||||
literal(False).label("is_verified"),
|
||||
literal(None).label("contact_phone"),
|
||||
literal(None).label("contact_email"),
|
||||
literal(None).label("website"),
|
||||
literal(None).label("specialization_tags"),
|
||||
literal("staged_data").label("source"),
|
||||
)
|
||||
if staging_conditions:
|
||||
staging_stmt = staging_stmt.where(*staging_conditions)
|
||||
|
||||
# --- 3. LEKÉRDEZÉS: Crowdsourced adatok (marketplace.service_providers) ---
|
||||
crowd_conditions = []
|
||||
if q_tokens:
|
||||
token_conditions = []
|
||||
for token in q_tokens:
|
||||
token_conditions.append(
|
||||
ServiceProvider.name.ilike(f"%{token}%")
|
||||
)
|
||||
crowd_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
crowd_conditions.append(
|
||||
ServiceProvider.address.ilike(f"%{city}%")
|
||||
)
|
||||
|
||||
crowd_stmt = select(
|
||||
ServiceProvider.id.label("id"),
|
||||
ServiceProvider.name.label("name"),
|
||||
literal(None).label("city"),
|
||||
ServiceProvider.address.label("address"),
|
||||
literal(None).label("address_zip"),
|
||||
literal(None).label("address_street_name"),
|
||||
literal(None).label("address_street_type"),
|
||||
literal(None).label("address_house_number"),
|
||||
literal(False).label("is_verified"),
|
||||
literal(None).label("contact_phone"),
|
||||
literal(None).label("contact_email"),
|
||||
literal(None).label("website"),
|
||||
literal(None).label("specialization_tags"),
|
||||
literal("crowd_added").label("source"),
|
||||
)
|
||||
if crowd_conditions:
|
||||
crowd_stmt = crowd_stmt.where(*crowd_conditions)
|
||||
|
||||
# --- UNION ---
|
||||
union_parts = [org_stmt]
|
||||
union_parts.append(staging_stmt)
|
||||
union_parts.append(crowd_stmt)
|
||||
|
||||
# UNION végrehajtása
|
||||
if len(union_parts) == 1:
|
||||
base_query = union_parts[0]
|
||||
else:
|
||||
base_query = union_all(*union_parts)
|
||||
|
||||
# Teljes darabszám
|
||||
count_subquery = base_query.subquery()
|
||||
count_stmt = select(func.count()).select_from(count_subquery)
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Lapozott lekérdezés
|
||||
paginated_query = base_query.order_by("source").offset(offset).limit(limit)
|
||||
result = await db.execute(paginated_query)
|
||||
rows = result.fetchall()
|
||||
|
||||
for row in rows:
|
||||
tags_list: list = []
|
||||
spec_tags = getattr(row, "specialization_tags", None)
|
||||
if spec_tags and isinstance(spec_tags, dict):
|
||||
user_tags = spec_tags.get("user_tags", [])
|
||||
if user_tags:
|
||||
tags_list = list(user_tags)
|
||||
|
||||
results.append(
|
||||
ProviderSearchResult(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
city=row.city,
|
||||
address=row.address,
|
||||
address_zip=getattr(row, "address_zip", None),
|
||||
address_street_name=getattr(row, "address_street_name", None),
|
||||
address_street_type=getattr(row, "address_street_type", None),
|
||||
address_house_number=getattr(row, "address_house_number", None),
|
||||
contact_phone=getattr(row, "contact_phone", None),
|
||||
contact_email=getattr(row, "contact_email", None),
|
||||
website=getattr(row, "website", None),
|
||||
tags=tags_list,
|
||||
source=row.source,
|
||||
is_verified=row.is_verified,
|
||||
)
|
||||
)
|
||||
|
||||
page = (offset // limit) + 1 if limit > 0 else 1
|
||||
|
||||
return ProviderSearchResponse(
|
||||
results=results,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=limit,
|
||||
)
|
||||
|
||||
|
||||
async def quick_add_provider(
|
||||
db: AsyncSession,
|
||||
data: ProviderQuickAddIn,
|
||||
user_id: int,
|
||||
) -> ProviderQuickAddResponse:
|
||||
"""
|
||||
Gyors szolgáltató felvétel crowdsourcingból.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
||||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
||||
|
||||
Folyamat:
|
||||
1. Kategória ellenőrzése (expertise_tags)
|
||||
2. Organization létrehozása (is_verified=False, org_type='service_provider')
|
||||
3. ServiceProfile létrehozása
|
||||
4. ServiceExpertise kapcsolat létrehozása
|
||||
5. Branch létrehozása a címmel
|
||||
6. OrganizationMember létrehozása a felhasználóhoz
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
data: ProviderQuickAddIn
|
||||
user_id: A felhasználó ID-ja
|
||||
|
||||
Returns:
|
||||
ProviderQuickAddResponse
|
||||
|
||||
Raises:
|
||||
ValueError: Ha a kategória nem létezik
|
||||
"""
|
||||
# 1. Kategória ellenőrzése
|
||||
category = await db.get(ExpertiseTag, data.category_id)
|
||||
if not category:
|
||||
raise ValueError(f"Category with id={data.category_id} not found")
|
||||
|
||||
# 2. Organization létrehozása
|
||||
folder_slug = hashlib.md5(
|
||||
f"{data.name}-{uuid.uuid4()}".encode()
|
||||
).hexdigest()[:12]
|
||||
|
||||
# P1 CRITICAL ALIGN: address_street_name, address_street_type, address_house_number
|
||||
# használata a régi street helyett.
|
||||
org = Organization(
|
||||
name=data.name[:100],
|
||||
full_name=data.name,
|
||||
display_name=data.name[:50],
|
||||
folder_slug=folder_slug,
|
||||
org_type=OrgType.service_provider,
|
||||
is_verified=False,
|
||||
status="pending_verification",
|
||||
subscription_plan="FREE",
|
||||
base_asset_limit=1,
|
||||
purchased_extra_slots=0,
|
||||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||||
external_integration_config={"source": "crowdsourced"},
|
||||
is_ownership_transferable=True,
|
||||
address_city=data.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
owner_id=None,
|
||||
first_registered_at=datetime.now(timezone.utc),
|
||||
current_lifecycle_started_at=datetime.now(timezone.utc),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
|
||||
# 3. ServiceProfile létrehozása
|
||||
fingerprint = hashlib.sha256(
|
||||
f"quick-add-{org.id}-{datetime.now().isoformat()}".encode()
|
||||
).hexdigest()[:64]
|
||||
|
||||
# specialization_tags összeállítása: primary_category + user-supplied tags
|
||||
spec_tags = {"primary_category": category.key}
|
||||
if data.tags:
|
||||
spec_tags["user_tags"] = data.tags
|
||||
|
||||
profile = ServiceProfile(
|
||||
organization_id=org.id,
|
||||
fingerprint=fingerprint,
|
||||
status="ghost",
|
||||
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
|
||||
specialization_tags=spec_tags,
|
||||
contact_phone=data.contact_phone,
|
||||
contact_email=data.contact_email,
|
||||
website=data.website,
|
||||
)
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
|
||||
# 4. ServiceExpertise kapcsolat
|
||||
expertise = ServiceExpertise(
|
||||
service_id=profile.id,
|
||||
expertise_id=data.category_id,
|
||||
confidence_level=50, # Közepes bizonyosság (crowdsourced)
|
||||
)
|
||||
db.add(expertise)
|
||||
|
||||
# 5. Branch létrehozása atomizált címmezőkkel
|
||||
branch = Branch(
|
||||
organization_id=org.id,
|
||||
name=data.name[:100],
|
||||
is_main=True,
|
||||
city=data.city,
|
||||
postal_code=data.address_zip,
|
||||
street_name=data.address_street_name,
|
||||
street_type=data.address_street_type,
|
||||
house_number=data.address_house_number,
|
||||
status="active",
|
||||
)
|
||||
db.add(branch)
|
||||
|
||||
# =====================================================================
|
||||
# 7. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
|
||||
# =====================================================================
|
||||
earned_points = await _award_provider_points(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
action_key="ADD_NEW_PROVIDER",
|
||||
)
|
||||
# =====================================================================
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(org)
|
||||
|
||||
logger.info(
|
||||
f"Quick-add provider created: org_id={org.id}, name={data.name}, "
|
||||
f"user_id={user_id}, earned_points={earned_points}"
|
||||
)
|
||||
|
||||
return ProviderQuickAddResponse(
|
||||
id=org.id,
|
||||
name=data.name,
|
||||
status="pending_verification",
|
||||
message="Szolgáltató sikeresen rögzítve. Ellenőrzés alatt.",
|
||||
earned_points=earned_points,
|
||||
)
|
||||
|
||||
|
||||
async def update_provider(
|
||||
db: AsyncSession,
|
||||
provider_id: int,
|
||||
data: ProviderUpdateIn,
|
||||
user_id: int,
|
||||
) -> ProviderUpdateResponse:
|
||||
"""
|
||||
Szolgáltató adatainak szerkesztése.
|
||||
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
||||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
||||
|
||||
Multi-source update (2026-06-17): Támogatja mindhárom forrást:
|
||||
1. fleet.organizations (verified orgs) - közvetlen frissítés
|
||||
2. marketplace.service_staging (robot adatok) - migrálás Organization-be
|
||||
3. marketplace.service_providers (crowdsourced) - migrálás Organization-be
|
||||
|
||||
Frissíti az Organization és a ServiceProfile rekordokat a megadott
|
||||
adatokkal. A forrást (source) a backend állítja be, a frontend SOHA
|
||||
nem küldheti azt!
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
provider_id: A szolgáltató ID-ja
|
||||
data: ProviderUpdateIn séma
|
||||
user_id: A szerkesztő felhasználó ID-ja
|
||||
|
||||
Returns:
|
||||
ProviderUpdateResponse
|
||||
|
||||
Raises:
|
||||
ValueError: Ha a szolgáltató nem található
|
||||
"""
|
||||
# 1. Organization lekérdezése
|
||||
org = await db.get(Organization, provider_id)
|
||||
|
||||
if not org:
|
||||
# 1b. Ha nincs Organization-ben, ServiceStaging-ben keresünk
|
||||
staging = await db.get(ServiceStaging, provider_id)
|
||||
if staging:
|
||||
# Migráljuk Organization-be
|
||||
org = Organization(
|
||||
id=staging.id,
|
||||
name=staging.name[:100],
|
||||
full_name=staging.name,
|
||||
display_name=staging.name[:50],
|
||||
org_type=OrgType.service_provider,
|
||||
address_city=staging.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=f"sp-{staging.id}-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"Provider migrated from ServiceStaging: staging_id={provider_id}, "
|
||||
f"new org_id={org.id}, user_id={user_id}"
|
||||
)
|
||||
else:
|
||||
# 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk
|
||||
crowd = await db.get(ServiceProvider, provider_id)
|
||||
if crowd:
|
||||
# Migráljuk Organization-be
|
||||
org = Organization(
|
||||
id=crowd.id,
|
||||
name=crowd.name[:100],
|
||||
full_name=crowd.name,
|
||||
display_name=crowd.name[:50],
|
||||
org_type=OrgType.service_provider,
|
||||
address_city=data.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=f"cr-{crowd.id}-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db.add(org)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"Provider migrated from ServiceProvider: crowd_id={provider_id}, "
|
||||
f"new org_id={org.id}, user_id={user_id}"
|
||||
)
|
||||
else:
|
||||
# 1d. Ha egyikben sem található
|
||||
raise ValueError(f"Provider with id={provider_id} not found")
|
||||
|
||||
# 2. Organization mezők frissítése atomizált címmezőkkel
|
||||
# ADATVÉDELMI SZABÁLY (2026-06-17): Csak akkor írjuk felül a mezőt,
|
||||
# ha a frontend explicit értéket küldött (nem None). Ez megakadályozza,
|
||||
# hogy a meglévő címadatok véletlenül null-ra állítódjanak, amikor
|
||||
# a felhasználó csak más mezőket szerkeszt.
|
||||
org.name = data.name[:100]
|
||||
org.full_name = data.name
|
||||
org.display_name = data.name[:50]
|
||||
if data.city is not None:
|
||||
org.address_city = data.city
|
||||
if data.address_zip is not None:
|
||||
org.address_zip = data.address_zip
|
||||
if data.address_street_name is not None:
|
||||
org.address_street_name = data.address_street_name
|
||||
if data.address_street_type is not None:
|
||||
org.address_street_type = data.address_street_type
|
||||
if data.address_house_number is not None:
|
||||
org.address_house_number = data.address_house_number
|
||||
|
||||
# 3. ServiceProfile lekérdezése (ha létezik)
|
||||
profile_stmt = select(ServiceProfile).where(
|
||||
ServiceProfile.organization_id == org.id
|
||||
)
|
||||
profile_result = await db.execute(profile_stmt)
|
||||
profile = profile_result.scalar_one_or_none()
|
||||
|
||||
if profile:
|
||||
# 4. ServiceProfile mezők frissítése
|
||||
profile.contact_phone = data.contact_phone
|
||||
profile.contact_email = data.contact_email
|
||||
profile.website = data.website
|
||||
|
||||
# specialization_tags frissítése
|
||||
if data.tags is not None:
|
||||
spec_tags = profile.specialization_tags or {}
|
||||
spec_tags["user_tags"] = data.tags
|
||||
profile.specialization_tags = spec_tags
|
||||
|
||||
logger.info(
|
||||
f"Provider update: org_id={org.id}, profile_id={profile.id}, "
|
||||
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, "
|
||||
f"website={data.website}, tags={data.tags}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Provider update: org_id={org.id} has no ServiceProfile. "
|
||||
f"Only Organization fields updated."
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Provider updated successfully: org_id={org.id}, "
|
||||
f"name={data.name}, user_id={user_id}"
|
||||
)
|
||||
|
||||
return ProviderUpdateResponse(
|
||||
id=org.id,
|
||||
name=data.name,
|
||||
message="Szolgáltató adatai sikeresen frissítve.",
|
||||
)
|
||||
Reference in New Issue
Block a user