""" 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. 4-Level Category Hybrid Save (2026-06-17): =========================================== - update_provider: category_ids -> ServiceExpertise kapcsolatok frissítése - update_provider: new_tags -> új ExpertiseTag létrehozása (is_official=False, level=3) - A hibrid logika biztosítja, hogy a meglévő kapcsolatok ne sérüljenek """ import logging import re 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, delete 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, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType from app.models.gamification.gamification import PointRule, UserStats from app.schemas.provider import ( ProviderSearchResult, ProviderSearchResponse, ProviderQuickAddIn, ProviderQuickAddResponse, ProviderUpdateIn, ProviderUpdateResponse, CategoryInfo, ) 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. REFAKTOR (2026-06-30): A duplikált point_rules olvasási logika eltávolításra került. A pontértékeket most a GamificationService.award_points() olvassa ki a point_rules táblából az action_key alapján a process_activity() hívás során. Ez a függvény már csak a providers_added_count statisztikát kezeli. A pontok kiszámítása és naplózása a GamificationService-ben történik. 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. Pontok jóváírása a Gamification Service-en keresztül (action_key alapján) # A GamificationService.award_points() kezeli a point_rules olvasást, 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=0, reason=f"{action_key}", commit=False, # A hívó kezeli a commit-ot action_key=action_key, ) # 2. 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=0, 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 0 # Visszatérési érték már nem a pontszám, mert az award_points kezeli async def find_or_create_provider_by_name( db: AsyncSession, name: str, added_by_user_id: int, ) -> tuple: """ Keres vagy létrehoz egy ServiceProvider-t a megadott név alapján. Ez a függvény az expense auto-discovery hook része. Amikor egy user költséget rögzít és megad egy external_vendor_name-t, ez a függvény megkeresi, hogy létezik-e már a provider, és ha nem, létrehozza. Logika: 1. Pontos match keresése ServiceProvider.name alapján (kisbetűs, space-sztrippelt) 2. Ha nem létezik → új provider létrehozása PENDING státusszal, validation_score=10, source=import, vissza: PROVIDER_DISCOVERY 3. Ha létezik és APPROVED → vissza: PROVIDER_VERIFIED_USE 4. Ha létezik és nem APPROVED → vissza: PROVIDER_CONFIRMATION 5. A függvény meghívja a _award_provider_points()-t a megfelelő action_key-kel Args: db: AsyncSession name: A provider neve (external_vendor_name) added_by_user_id: A költséget rögzítő user ID-ja Returns: tuple[ServiceProvider, str]: (provider, action_key) - action_key: "PROVIDER_DISCOVERY" | "PROVIDER_CONFIRMATION" | "PROVIDER_VERIFIED_USE" """ # 1. Pontos match keresése (kisbetűsen, space-sztrippelten) clean_name = name.strip() provider_stmt = select(ServiceProvider).where( func.lower(ServiceProvider.name) == func.lower(clean_name) ) provider_result = await db.execute(provider_stmt) provider = provider_result.scalar_one_or_none() if not provider: # 2. Ha nem létezik → létrehozás + PROVIDER_DISCOVERY new_provider = ServiceProvider( name=clean_name, address=clean_name, status=ModerationStatus.pending, source=SourceType.api, validation_score=10, added_by_user_id=added_by_user_id, ) db.add(new_provider) await db.flush() logger.info( f"Új provider felfedezve: name='{clean_name}', " f"provider_id={new_provider.id}, user_id={added_by_user_id}" ) # Gamification pont kiosztása await _award_provider_points( db=db, user_id=added_by_user_id, action_key="PROVIDER_DISCOVERY", ) return new_provider, "PROVIDER_DISCOVERY" # 3. Ha létezik, státusz alapján döntés # P0 CRITICAL (Card #362): USE_UNVERIFIED_PROVIDER vs PROVIDER_CONFIRMATION # - Ha a provider APPROVED -> PROVIDER_VERIFIED_USE (100 XP) # - Ha a provider PENDING es a hasznalo user a provider letrehozoja -> PROVIDER_CONFIRMATION (150 XP) # - Ha a provider PENDING es a hasznalo user NEM a provider letrehozoja -> USE_UNVERIFIED_PROVIDER (200 XP) # Ez megakadalyozza az XP farmingot (sajat provider hasznalata sajat maganak). if provider.status == ModerationStatus.approved: action_key = "PROVIDER_VERIFIED_USE" elif provider.added_by_user_id == added_by_user_id: # A provider letrehozoja hasznalja a sajat pending provideret action_key = "PROVIDER_CONFIRMATION" else: # Mas user hasznal egy pending providert action_key = "USE_UNVERIFIED_PROVIDER" logger.info( f"Meglevo provider hasznalata: name='{clean_name}', " f"provider_id={provider.id}, action_key='{action_key}', " f"user_id={added_by_user_id}, provider_creator_id={provider.added_by_user_id}" ) # Gamification pont kiosztása await _award_provider_points( db=db, user_id=added_by_user_id, action_key=action_key, ) return provider, action_key async def search_providers( db: AsyncSession, q: Optional[str] = None, category: Optional[str] = None, city: Optional[str] = None, category_ids: Optional[List[int]] = None, org_type: 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. FEATURE (2026-06-17): category_ids szűrő — SZIGORÚ AND KAPCSOLAT. - Ha több kategória ID van megadva, a szolgáltatónak MINDEGYIKHEZ tartoznia kell (AND), nem elég, ha csak az egyikhez (OR). - Nincs hierarchikus terjeszkedés: csak a pontosan megadott ID-kat ellenőrizzük a ServiceExpertise táblában. FEATURE (2026-06-17): Keresés szigorítása. - A q (free-text) keresés csak a name és aliases mezőkben keres (ILIKE), a tags mezőt NEM vizsgálja. - A city keresés exact match vagy StartsWith, nem ILIKE. FEATURE (2026-06-17): Kategória adatok visszaadása. - Minden ProviderSearchResult tartalmazza a kapcsolódó kategóriák (ExpertiseTag) ID-ját, nevét és szintjét a categories mezőben. 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 category_ids: Kategória ID-k listája (SZIGORÚ AND szűrés) org_type: Szervezet típus szűrő (pl. 'service_provider', 'insurance') 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.is_deleted == False, ] if org_type: org_conditions.append(Organization.org_type == org_type) else: org_conditions.append(Organization.org_type == OrgType.service_provider) 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}%"), ) ) org_conditions.append(and_(*token_conditions)) if city: # SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith city_clean = city.strip() org_conditions.append( or_( Organization.address_city == city_clean, Organization.address_city.ilike(f"{city_clean}%"), Organization.address_zip == city_clean, Organization.address_zip.ilike(f"{city_clean}%"), ) ) # FEATURE (2026-06-17): Kategória szűrés category_ids alapján. # SZIGORÚ AND KAPCSOLAT (2026-06-17): Ha több kategória ID van megadva, # a szolgáltatónak MINDEGYIKHEZ tartoznia kell. Nincs hierarchikus # terjeszkedés (path LIKE), csak a pontosan megadott ID-kat ellenőrizzük. # P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids None, # nem iterálunk rajta. safe_category_ids = category_ids or [] if safe_category_ids: # Minden egyes megadott category_id-hoz külön subquery-t készítünk, # és AND kapcsolattal fűzzük össze őket. Ez biztosítja, hogy a # szolgáltató minden kategóriával rendelkezzen. for cid in safe_category_ids: profile_subq = ( select(ServiceExpertise.service_id) .where(ServiceExpertise.expertise_id == cid) .subquery() ) org_conditions.append( ServiceProfile.id.in_(select(profile_subq.c.service_id)) ) # 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.plus_code.label("plus_code"), 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: # SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith city_clean = city.strip() staging_conditions.append( or_( ServiceStaging.city == city_clean, ServiceStaging.city.ilike(f"{city_clean}%"), ServiceStaging.postal_code == city_clean, ServiceStaging.postal_code.ilike(f"{city_clean}%"), ) ) 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(None).label("plus_code"), 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: # SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith city_clean = city.strip() crowd_conditions.append( or_( ServiceProvider.address == city_clean, ServiceProvider.address.ilike(f"{city_clean}%"), ) ) 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(None).label("plus_code"), 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() # FEATURE (2026-06-17): Kategória adatok előtöltése. # Lekérdezzük az összes olyan ServiceProfile-hoz tartozó ExpertiseTag-eket, # amelyek a találati listában szereplő organization ID-khoz tartoznak. # Ezt egyetlen batch lekérdezéssel tesszük a N+1 probléma elkerülése érdekében. org_ids = [row.id for row in rows if row.source in ("verified_org", "crowd_added")] org_categories_map: dict[int, list[dict]] = {} if org_ids: cat_stmt = ( select( ServiceProfile.organization_id, ExpertiseTag.id, ExpertiseTag.name_hu, ExpertiseTag.name_en, ExpertiseTag.level, ExpertiseTag.key, ) .select_from(ServiceExpertise) .join(ExpertiseTag, ServiceExpertise.expertise_id == ExpertiseTag.id) .join(ServiceProfile, ServiceExpertise.service_id == ServiceProfile.id) .where( ServiceProfile.organization_id.in_(org_ids), ) ) cat_result = await db.execute(cat_stmt) cat_rows = cat_result.fetchall() for cat_row in cat_rows: org_id = cat_row.organization_id if org_id not in org_categories_map: org_categories_map[org_id] = [] org_categories_map[org_id].append({ "id": cat_row.id, "name_hu": cat_row.name_hu, "name_en": cat_row.name_en, "level": cat_row.level, "key": cat_row.key, }) 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) # Kategória adatok összeállítása row_categories = org_categories_map.get(row.id, []) categories_out = [ CategoryInfo( id=cat["id"], name_hu=cat["name_hu"], name_en=cat["name_en"], level=cat["level"], key=cat["key"], ) for cat in row_categories ] 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), plus_code=getattr(row, "plus_code", None), contact_phone=getattr(row, "contact_phone", None), contact_email=getattr(row, "contact_email", None), website=getattr(row, "website", None), tags=tags_list, categories=categories_out, 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. P0 HYBRID VENDOR REFACTOR (2026-06-25): ======================================== A quick_add_provider() MOST MÁR ServiceProvider-t hoz létre, NEM Organization-t. Ez a P0 Architecture Refactor (Option C - Hybrid) része. Új folyamat: 1. (Opcionális) Elsődleges kategória ellenőrzése (expertise_tags) 2. ServiceProvider létrehozása (marketplace.service_providers) 3. ServiceProfile létrehozása (service_provider_id hivatkozással) 4. ServiceExpertise kapcsolatok létrehozása (category_id + category_ids + new_tags) 5. 🎮 Gamification pontozás NINCS: Organization, Branch, OrganizationMember létrehozás! Args: db: AsyncSession data: ProviderQuickAddIn user_id: A felhasználó ID-ja Returns: ProviderQuickAddResponse (id = ServiceProvider.id) Raises: ValueError: Ha a kategória nem létezik """ # 1. (Opcionális) Elsődleges kategória ellenőrzése primary_category_key = None if data.category_id is not None: category = await db.get(ExpertiseTag, data.category_id) if not category: raise ValueError(f"Category with id={data.category_id} not found") primary_category_key = category.key # 2. ServiceProvider létrehozása (NEM Organization!) provider = ServiceProvider( name=data.name, address=", ".join(filter(None, [ data.address_street_name or "", data.address_street_type or "", data.address_house_number or "", ])) or data.name, category=primary_category_key, # Atomizált címmezők 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, plus_code=data.plus_code, # Kapcsolatfelvételi adatok contact_phone=data.contact_phone, contact_email=data.contact_email, website=data.website, # Státusz és forrás status="pending", source="manual", validation_score=50, added_by_user_id=user_id, ) db.add(provider) await db.flush() # 3. ServiceProfile létrehozása (service_provider_id hivatkozással) fingerprint = hashlib.sha256( f"quick-add-{provider.id}-{datetime.now().isoformat()}".encode() ).hexdigest()[:64] # specialization_tags összeállítása: primary_category + user-supplied tags spec_tags: dict = {} if primary_category_key: spec_tags["primary_category"] = primary_category_key if data.tags: spec_tags["user_tags"] = data.tags profile = ServiceProfile( service_provider_id=provider.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 kapcsolatok létrehozása (4-LEVEL CATEGORY) # ===================================================================== all_category_ids: List[int] = [] # 4a. Elsődleges kategória (ha meg van adva) if data.category_id is not None: all_category_ids.append(data.category_id) # 4b. category_ids a 4-level checkboxokból safe_category_ids = data.category_ids or [] if safe_category_ids: all_category_ids.extend(safe_category_ids) # 4c. new_tags -> Új ExpertiseTag létrehozása + kapcsolat safe_new_tags = data.new_tags or [] new_tag_ids: List[int] = [] if safe_new_tags: new_tag_ids = await _create_new_tags( db=db, service_profile_id=profile.id, new_tag_names=safe_new_tags, ) if new_tag_ids: all_category_ids.extend(new_tag_ids) # 4d. ServiceExpertise kapcsolatok létrehozása if all_category_ids: unique_ids = list(set(all_category_ids)) for expertise_id in unique_ids: expertise = ServiceExpertise( service_id=profile.id, expertise_id=expertise_id, confidence_level=50, ) db.add(expertise) logger.info( f"ServiceExpertise kapcsolatok létrehozva: service_id={profile.id}, " f"expertise_ids={unique_ids}" ) # ===================================================================== # ===================================================================== # 5. 🎮 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(provider) logger.info( f"Quick-add provider created: service_provider_id={provider.id}, name={data.name}, " f"user_id={user_id}, earned_points={earned_points}" ) return ProviderQuickAddResponse( id=provider.id, name=data.name, status="pending_verification", message="Szolgáltató sikeresen rögzítve. Ellenőrzés alatt.", earned_points=earned_points, ) async def _sync_service_expertises( db: AsyncSession, service_profile_id: int, category_ids: List[int], ) -> None: """ Szinkronizálja a ServiceExpertise kapcsolatokat a megadott kategória ID-k alapján. 4-Level Category Hybrid Save (2026-06-17): - Eltávolítja a meglévő kapcsolatokat, amelyek nincsenek az új listában - Hozzáadja az új kapcsolatokat, amelyek még nem léteznek Args: db: AsyncSession service_profile_id: A ServiceProfile ID-ja category_ids: Az új kategória ID-k listája """ if not category_ids: return # Meglévő kapcsolatok lekérése existing_stmt = select(ServiceExpertise).where( ServiceExpertise.service_id == service_profile_id ) existing_result = await db.execute(existing_stmt) existing_expertises = existing_result.scalars().all() existing_ids = {e.expertise_id for e in existing_expertises} # Eltávolítandó kapcsolatok (amik nincsenek az új listában) ids_to_remove = existing_ids - set(category_ids) if ids_to_remove: delete_stmt = delete(ServiceExpertise).where( ServiceExpertise.service_id == service_profile_id, ServiceExpertise.expertise_id.in_(ids_to_remove), ) await db.execute(delete_stmt) logger.info( f"Eltávolított ServiceExpertise kapcsolatok: service_id={service_profile_id}, " f"expertise_ids={ids_to_remove}" ) # Hozzáadandó kapcsolatok (amik újak) ids_to_add = set(category_ids) - existing_ids for expertise_id in ids_to_add: new_expertise = ServiceExpertise( service_id=service_profile_id, expertise_id=expertise_id, confidence_level=50, ) db.add(new_expertise) logger.info( f"Új ServiceExpertise kapcsolat: service_id={service_profile_id}, " f"expertise_id={expertise_id}" ) def _slugify(text: str) -> str: """ Biztonságos slug létrehozása magyar ékezetes karakterekkel. - Kisbetűsítés - Ékezetes karakterek cseréje (á→a, é→e, í→i, ó→o, ö→o, ő→o, ú→u, ü→u, ű→u) - Nem alfanumerikus karakterek cseréje alulvonásra - Maximum 50 karakter """ replacements = { 'á': 'a', 'é': 'e', 'í': 'i', 'ó': 'o', 'ö': 'o', 'ő': 'o', 'ú': 'u', 'ü': 'u', 'ű': 'u', 'Á': 'a', 'É': 'e', 'Í': 'i', 'Ó': 'o', 'Ö': 'o', 'Ő': 'o', 'Ú': 'u', 'Ü': 'u', 'Ű': 'u', } result = text.lower().strip() for hungarian, ascii_char in replacements.items(): result = result.replace(hungarian, ascii_char) # Replace any non-alphanumeric character (except underscore) with underscore result = re.sub(r'[^a-z0-9_]', '_', result) # Collapse multiple underscores result = re.sub(r'_+', '_', result) # Strip leading/trailing underscores result = result.strip('_') return result[:50] async def _create_new_tags( db: AsyncSession, service_profile_id: int, new_tag_names: List[str], ) -> List[int]: """ Létrehoz új ExpertiseTag rekordokat a user által gépelt címkékből. P0 CRITICAL BUGFIX (2026-06-17): try-except blokk + slugify + hibanaplózás. 4-Level Category Hybrid Save (2026-06-17): - Ha a címke string már létezik az ExpertiseTag táblában, visszaadja az ID-ját - Ha a címke vadiúj, létrehozza is_official=False és level=3 értékekkel Args: db: AsyncSession service_profile_id: A ServiceProfile ID-ja (a kapcsolathoz) new_tag_names: A user által gépelt új címkenevek listája Returns: List[int]: A létrehozott/megtalált ExpertiseTag ID-k listája """ if not new_tag_names: return [] created_ids = [] for tag_name in new_tag_names: tag_name = tag_name.strip() if not tag_name: continue try: # Ellenőrizzük, hogy létezik-e már existing_stmt = select(ExpertiseTag).where( or_( ExpertiseTag.key == _slugify(tag_name), ExpertiseTag.name_hu == tag_name, ExpertiseTag.name_en == tag_name, ) ) existing_result = await db.execute(existing_stmt) existing_tag = existing_result.scalar_one_or_none() if existing_tag: # Már létezik, használjuk a meglévő ID-t created_ids.append(existing_tag.id) logger.info( f"Meglévő címke használata: tag_name={tag_name}, " f"existing_id={existing_tag.id}" ) else: # Vadiúj címke létrehozása slugify-jal new_key = _slugify(tag_name) new_tag = ExpertiseTag( key=new_key, name_hu=tag_name, name_en=tag_name, level=3, is_official=False, category="user_created", parent_id=None, path=None, search_keywords=[tag_name.lower()], description=f"User-created tag: {tag_name}", ) db.add(new_tag) await db.flush() created_ids.append(new_tag.id) logger.info( f"Új címke létrehozva: tag_name={tag_name}, " f"new_id={new_tag.id}, key={new_key}" ) except Exception as e: logger.error( f"HIBA az új címke létrehozásakor: tag_name={tag_name}, " f"error={e}", exc_info=True ) # Nem szakítjuk meg a teljes folyamatot egyetlen hibás címke miatt continue return created_ids 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. 4-Level Category Hybrid Save (2026-06-17): - category_ids: ServiceExpertise kapcsolatok frissítése - new_tags: Új ExpertiseTag létrehozása (is_official=False, level=3) majd ServiceExpertise kapcsolat létrehozása 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ó PermissionError: Ha a felhasználó nem tagja a szervezetnek """ # 1. Organization lekérdezése org = await db.get(Organization, provider_id) if org: # ===================================================================== # 1a. 🔐 ACCESS CONTROL ELLENŐRZÉS (P0 CRITICAL BUGFIX 2026-06-17) # ===================================================================== # Csak azok a felhasználók szerkeszthetik a szolgáltatót, akik # OWNER vagy ADMIN szerepkörű tagok a szervezetben. member_stmt = select(OrganizationMember).where( OrganizationMember.organization_id == org.id, OrganizationMember.user_id == user_id, OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN]), ) member_result = await db.execute(member_stmt) member = member_result.scalar_one_or_none() if not member: logger.warning( f"Access denied: user_id={user_id} attempted to update " f"org_id={org.id} but is not an OWNER or ADMIN member" ) raise PermissionError( f"Nincs jogosultságod szerkeszteni ezt a szolgáltatót. " f"Csak a szervezet tulajdonosa vagy adminisztrátora " f"módosíthatja az adatokat." ) # ===================================================================== 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 now = datetime.now(timezone.utc) 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=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12], first_registered_at=now, current_lifecycle_started_at=now, created_at=now, 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={}, is_ownership_transferable=True, ) 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 now = datetime.now(timezone.utc) 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=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12], first_registered_at=now, current_lifecycle_started_at=now, created_at=now, 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={}, is_ownership_transferable=True, ) 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 if data.plus_code is not None: org.plus_code = data.plus_code # 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 # ===================================================================== # 5. 🏗️ 4-LEVEL CATEGORY HYBRID SAVE (2026-06-17) # ===================================================================== # P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids vagy # new_tags None, nem iterálunk rajtuk, és nem hívjuk meg az extend-et. # Összegyűjtjük az összes kategória ID-t (meglévő + új) all_category_ids: List[int] = [] # 5a. category_ids -> ServiceExpertise kapcsolatok frissítése safe_category_ids = data.category_ids or [] if safe_category_ids: all_category_ids.extend(safe_category_ids) # 5b. new_tags -> Új ExpertiseTag létrehozása + kapcsolat safe_new_tags = data.new_tags or [] if safe_new_tags: new_tag_ids = await _create_new_tags( db=db, service_profile_id=profile.id, new_tag_names=safe_new_tags, ) if new_tag_ids: all_category_ids.extend(new_tag_ids) # 5c. Egyszeri szinkronizálás az összes kategória ID-val if all_category_ids: await _sync_service_expertises( db=db, service_profile_id=profile.id, category_ids=all_category_ids, ) # ===================================================================== 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}, " f"category_ids={data.category_ids}, new_tags={data.new_tags}" ) else: # P0 CRITICAL BUGFIX (2026-06-17): Ha nincs ServiceProfile, létrehozzuk. # Ez biztosítja, hogy a contact_phone, contact_email, website, tags # és category_ids adatok mentésre kerüljenek, még akkor is, ha a # szolgáltató korábban csak Organization-ként létezett. logger.warning( f"Provider update: org_id={org.id} has no ServiceProfile. " f"Creating one now." ) fingerprint = hashlib.sha256( f"update-create-{org.id}-{datetime.now().isoformat()}".encode() ).hexdigest()[:64] profile = ServiceProfile( organization_id=org.id, fingerprint=fingerprint, location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326), contact_phone=data.contact_phone, contact_email=data.contact_email, website=data.website, specialization_tags={"user_tags": data.tags} if data.tags else {}, status=ServiceStatus.active, ) db.add(profile) await db.flush() logger.info( f"ServiceProfile created for org_id={org.id}, profile_id={profile.id}" ) # 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 # ===================================================================== # 5. 🏗️ 4-LEVEL CATEGORY HYBRID SAVE (2026-06-17) # ===================================================================== # P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids vagy # new_tags None, nem iterálunk rajtuk, és nem hívjuk meg az extend-et. # Összegyűjtjük az összes kategória ID-t (meglévő + új) all_category_ids: List[int] = [] # 5a. category_ids -> ServiceExpertise kapcsolatok frissítése safe_category_ids = data.category_ids or [] if safe_category_ids: all_category_ids.extend(safe_category_ids) # 5b. new_tags -> Új ExpertiseTag létrehozása + kapcsolat safe_new_tags = data.new_tags or [] if safe_new_tags: new_tag_ids = await _create_new_tags( db=db, service_profile_id=profile.id, new_tag_names=safe_new_tags, ) if new_tag_ids: all_category_ids.extend(new_tag_ids) # 5c. Egyszeri szinkronizálás az összes kategória ID-val if all_category_ids: await _sync_service_expertises( db=db, service_profile_id=profile.id, category_ids=all_category_ids, ) # ===================================================================== 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}, " f"category_ids={data.category_ids}, new_tags={data.new_tags}" ) # ===================================================================== # 6. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás szerkesztésért) # ===================================================================== # A felhasználó aktuális penalty_level-jétől (restriction_level) függően # kalkuláljuk a kapott pontot. A GamificationService.process_activity() # automatikusan alkalmazza a büntetési szorzót a _calculate_multiplier-en # keresztül, így: # - Szint 0 (L0) = 100% pont # - Szint 1 (L1) = 50% pont # - Szint 2 (L2) = 10% pont # - Szint 3 (L3) = 0% pont (blokkolt) earned_points = await _award_provider_points( db=db, user_id=user_id, action_key="UPDATE_PROVIDER", ) # ===================================================================== await db.commit() logger.info( f"Provider updated successfully: org_id={org.id}, " f"name={data.name}, user_id={user_id}, earned_points={earned_points}" ) return ProviderUpdateResponse( id=org.id, name=data.name, message="Szolgáltató adatai sikeresen frissítve.", earned_points=earned_points, )