szolgáltatók beálltásai, szerkesztése , létrehozása

This commit is contained in:
Roo
2026-06-17 11:52:25 +00:00
parent 213ba3b0f1
commit bf3a971ff1
56 changed files with 14421 additions and 1512 deletions

View File

@@ -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"])

View File

@@ -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"

View File

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

View 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,
}

View File

@@ -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")

View File

@@ -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.",
)

View 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",
]

View 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

View File

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

View File

@@ -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"))

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -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

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