Files
service-finder/backend/app/services/provider_service.py
2026-07-20 11:17:52 +00:00

1286 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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, join
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.identity.address import Address, GeoPostalCode
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise
from app.models.marketplace.staged_data import ServiceStaging
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
from app.models.gamification.gamification import PointRule, UserStats
from app.schemas.address import AddressOut
from app.schemas.provider import (
ProviderSearchResult,
ProviderSearchResponse,
ProviderQuickAddIn,
ProviderQuickAddResponse,
ProviderUpdateIn,
ProviderUpdateResponse,
CategoryInfo,
)
from app.services.gamification_service import gamification_service
from app.services.address_manager import AddressManager
from app.schemas.address import AddressIn
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.
P0 ARCHITECTURE CLEANUP (2026-07-01):
======================================
Gamification/XP logika ELTÁVOLÍTVA ebből a service rétegből.
A gamification hívások az API rétegbe (expenses.py) kerültek át.
Ez a függvény immár tiszta adatbázis service — nem tud a gamification-ről.
Visszatérési érték módosítva: (provider, created) tuple.
- created=True: új provider lett létrehozva (felfedezés)
- created=False: meglévő provider lett lekérve
pg_trgm similarity threshold: 0.3 -> 0.6 (hamis pozitív találatok csökkentése)
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 (ILIKE, kisbetűs)
2. Fuzzy match trigram similarity-vel (pg_trgm) — ha similarity > 0.6
3. Ha nem létezik → új provider létrehozása PENDING státusszal,
validation_score=0, source=import, vissza: created=True
4. Ha létezik → vissza: created=False
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, bool]: (provider, created)
- created=True: új provider lett létrehozva
- created=False: meglévő provider lett lekérve
"""
# 1. Pontos match keresése (ILIKE, kisbetűsen, space-sztrippelten)
clean_name = name.strip()
provider_stmt = select(ServiceProvider).where(
ServiceProvider.name.ilike(clean_name)
)
provider_result = await db.execute(provider_stmt)
provider = provider_result.scalar_one_or_none()
# 2. Fuzzy match trigram similarity-vel (pg_trgm), ha nincs pontos találat
# P0 ARCHITECTURE CLEANUP: threshold 0.3 -> 0.6 a hamis pozitívok csökkentésére
if not provider:
fuzzy_stmt = select(ServiceProvider).where(
func.similarity(ServiceProvider.name, clean_name) > 0.6
).order_by(
func.similarity(ServiceProvider.name, clean_name).desc()
).limit(1)
fuzzy_result = await db.execute(fuzzy_stmt)
provider = fuzzy_result.scalar_one_or_none()
if not provider:
# 3. Ha nem létezik → létrehozás, created=True
new_provider = ServiceProvider(
name=clean_name,
address=clean_name,
status=ModerationStatus.pending,
source=SourceType.api_import, # "import" — API import forrás
validation_score=0,
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}"
)
return new_provider, True
# 4. Ha létezik, visszaadjuk created=False
logger.info(
f"Meglevo provider hasznalata: name='{clean_name}', "
f"provider_id={provider.id}, "
f"user_id={added_by_user_id}, provider_creator_id={provider.added_by_user_id}"
)
return provider, False
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:
# P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city.
# A város keresés most már ILIKE %{city}% (bárhol a string-ben),
# nem csak StartsWith. Ez lehetővé teszi a részleges városnév
# keresést is (pl. "bud" → "Budapest").
city_clean = city.strip()
org_conditions.append(
or_(
GeoPostalCode.city == city_clean,
GeoPostalCode.city.ilike(f"%{city_clean}%"),
GeoPostalCode.zip_code == city_clean,
GeoPostalCode.zip_code.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))
)
# P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
# helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
# a város, irányítószám és utca adatokat.
# LEFT JOIN-eket használunk, mert az address_id lehet NULL.
org_stmt = select(
Organization.id.label("id"),
Organization.name.label("name"),
GeoPostalCode.city.label("city"),
func.concat(
GeoPostalCode.zip_code,
literal(' '),
GeoPostalCode.city,
literal(', '),
Address.street_name,
literal(' '),
Address.street_type,
literal(' '),
Address.house_number,
).label("address"),
GeoPostalCode.zip_code.label("address_zip"),
Address.street_name.label("address_street_name"),
Address.street_type.label("address_street_type"),
Address.house_number.label("address_house_number"),
literal(None).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,
).outerjoin(
Address,
Address.id == Organization.address_id,
).outerjoin(
GeoPostalCode,
GeoPostalCode.id == Address.postal_code_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:
# P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city
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:
# P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city
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
]
# P3: Egységes cím objektum összeállítása a flat mezőkből
row_address_zip = getattr(row, "address_zip", None)
row_address_street_name = getattr(row, "address_street_name", None)
row_address_street_type = getattr(row, "address_street_type", None)
row_address_house_number = getattr(row, "address_house_number", None)
row_city = row.city
row_plus_code = getattr(row, "plus_code", None)
# P0 BUGFIX (2026-07-10): Null-safe AddressOut construction.
# A Pydantic v2 from_attributes=True mode-ban néha hibát dob,
# ha minden mező None. Itt try-excepttel védjük.
address_detail = None
if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]):
try:
address_detail = AddressOut(
zip=row_address_zip,
city=row_city,
street_name=row_address_street_name,
street_type=row_address_street_type,
house_number=row_address_house_number,
full_address_text=row.address,
)
except Exception as addr_e:
logger.warning(
f"Failed to create AddressOut for provider {row.id} ({row.name}): {addr_e}. "
f"Skipping address_detail."
)
address_detail = None
results.append(
ProviderSearchResult(
id=row.id,
name=row.name,
city=row_city,
address=row.address,
address_zip=row_address_zip,
address_street_name=row_address_street_name,
address_street_type=row_address_street_type,
address_house_number=row_address_house_number,
plus_code=row_plus_code,
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,
address_detail=address_detail,
)
)
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)
# ── AddressManager: Cím létrehozása a staging adataiból ──
staging_addr_in = AddressIn(
city=staging.city or "",
zip=data.address_zip or "",
street_name=data.address_street_name or "",
street_type=data.address_street_type or "",
house_number=data.address_house_number or "",
)
staging_address_id = await AddressManager.create_or_update(db, None, staging_addr_in)
org = Organization(
id=staging.id,
name=staging.name[:100],
full_name=staging.name,
display_name=staging.name[:50],
org_type=OrgType.service_provider,
address_id=staging_address_id,
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:
# ── AddressManager: Cím létrehozása a crowd adataiból ──
crowd_addr_in = AddressIn(
city=data.city or "",
zip=data.address_zip or "",
street_name=data.address_street_name or "",
street_type=data.address_street_type or "",
house_number=data.address_house_number or "",
)
crowd_address_id = await AddressManager.create_or_update(db, None, crowd_addr_in)
# 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_id=crowd_address_id,
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 AddressManager segítségével
# P3 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
# helyett az AddressManager.create_or_update() kezeli a címet.
# A flat mezők (city, address_zip, stb.) továbbra is támogatottak
# a ProviderUpdateIn sémában a backward compatibility miatt.
org.name = data.name[:100]
org.full_name = data.name
org.display_name = data.name[:50]
# ── Cím frissítése AddressManager segítségével ──
# Összegyűjtjük a flat mezőkből az AddressIn adatokat
addr_fields = {}
if data.city is not None:
addr_fields["city"] = data.city
if data.address_zip is not None:
addr_fields["zip"] = data.address_zip
if data.address_street_name is not None:
addr_fields["street_name"] = data.address_street_name
if data.address_street_type is not None:
addr_fields["street_type"] = data.address_street_type
if data.address_house_number is not None:
addr_fields["house_number"] = data.address_house_number
if data.plus_code is not None:
addr_fields["plus_code"] = data.plus_code
# Ha van address_detail, az elsőbbséget élvez a flat mezőkkel szemben
if data.address_detail is not None:
org.address_id = await AddressManager.create_or_update(
db, org.address_id, data.address_detail
)
elif addr_fields:
addr_in = AddressIn(**addr_fields)
org.address_id = await AddressManager.create_or_update(
db, org.address_id, addr_in
)
# 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,
)