Files
service-finder/backend/app/services/provider_service.py

1142 lines
44 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
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
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.
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,
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,
)