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

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