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