1220 lines
47 KiB
Python
1220 lines
47 KiB
Python
"""
|
||
Provider Service – Szolgáltató keresés, gyors felvétel és szerkesztés logikája.
|
||
|
||
Gamification Integration (2026-06-16):
|
||
=======================================
|
||
A Vezető Tervező utasítása szerint a crowdsourcing funkciókba
|
||
dinamikus Gamification horgok kerültek beépítésre.
|
||
|
||
ALAPELV: A pontértékek SOHA nincsenek beégetve (hardcoded) a kódba!
|
||
Minden pontszámot a gamification.point_rules táblából olvasunk ki
|
||
action_key alapján futásidőben.
|
||
|
||
Jelenlegi horgok:
|
||
- ADD_NEW_PROVIDER: 500 pont (adatbázisból) + providers_added_count növelés
|
||
|
||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||
=====================================================
|
||
- quick_add_provider: data.street -> data.address_street_name, address_street_type, address_house_number
|
||
- update_provider: data.street -> data.address_street_name, address_street_type, address_house_number
|
||
- search_providers: visszaadja az address_street_name, address_street_type, address_house_number mezőket
|
||
- A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek.
|
||
|
||
4-Level Category Hybrid Save (2026-06-17):
|
||
===========================================
|
||
- update_provider: category_ids -> ServiceExpertise kapcsolatok frissítése
|
||
- update_provider: new_tags -> új ExpertiseTag létrehozása (is_official=False, level=3)
|
||
- A hibrid logika biztosítja, hogy a meglévő kapcsolatok ne sérüljenek
|
||
"""
|
||
|
||
import logging
|
||
import re
|
||
import uuid
|
||
import hashlib
|
||
from datetime import datetime, timezone
|
||
from typing import Optional, List, Tuple
|
||
|
||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import joinedload
|
||
|
||
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
|
||
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging
|
||
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
|
||
from app.models.gamification.gamification import PointRule, UserStats
|
||
from app.schemas.provider import (
|
||
ProviderSearchResult,
|
||
ProviderSearchResponse,
|
||
ProviderQuickAddIn,
|
||
ProviderQuickAddResponse,
|
||
ProviderUpdateIn,
|
||
ProviderUpdateResponse,
|
||
CategoryInfo,
|
||
)
|
||
from app.services.gamification_service import gamification_service
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
async def _award_provider_points(
|
||
db: AsyncSession,
|
||
user_id: int,
|
||
action_key: str,
|
||
) -> int:
|
||
"""
|
||
Dinamikus pontjóváírás a gamification.point_rules tábla alapján.
|
||
|
||
REFAKTOR (2026-06-30): A duplikált point_rules olvasási logika eltávolításra
|
||
került. A pontértékeket most a GamificationService.award_points() olvassa ki
|
||
a point_rules táblából az action_key alapján a process_activity() hívás során.
|
||
|
||
Ez a függvény már csak a providers_added_count statisztikát kezeli.
|
||
A pontok kiszámítása és naplózása a GamificationService-ben történik.
|
||
|
||
Args:
|
||
db: AsyncSession
|
||
user_id: A felhasználó ID-ja
|
||
action_key: A pontszabály azonosítója (pl. 'ADD_NEW_PROVIDER')
|
||
|
||
Returns:
|
||
int: A jóváírt pontok száma (0 ha a szabály nem található vagy inaktív)
|
||
"""
|
||
# 1. Pontok jóváírása a Gamification Service-en keresztül (action_key alapján)
|
||
# A GamificationService.award_points() kezeli a point_rules olvasást, szorzókat,
|
||
# szintlépést, büntetés ledolgozást és a naplózást (PointsLedger).
|
||
await gamification_service.award_points(
|
||
db=db,
|
||
user_id=user_id,
|
||
amount=0,
|
||
reason=f"{action_key}",
|
||
commit=False, # A hívó kezeli a commit-ot
|
||
action_key=action_key,
|
||
)
|
||
|
||
# 2. Statisztika növelése (providers_added_count)
|
||
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
|
||
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
|
||
|
||
if stats:
|
||
stats.providers_added_count = UserStats.providers_added_count + 1
|
||
logger.info(
|
||
f"Statisztika frissítve: user_id={user_id}, "
|
||
f"providers_added_count={stats.providers_added_count}"
|
||
)
|
||
else:
|
||
# Ha még nincs UserStats rekordja, létrehozzuk
|
||
stats = UserStats(
|
||
user_id=user_id,
|
||
total_xp=0,
|
||
current_level=1,
|
||
providers_added_count=1,
|
||
)
|
||
db.add(stats)
|
||
logger.info(
|
||
f"Új UserStats rekord létrehozva: user_id={user_id}, "
|
||
f"providers_added_count=1"
|
||
)
|
||
|
||
return 0 # Visszatérési érték már nem a pontszám, mert az award_points kezeli
|
||
|
||
|
||
async def find_or_create_provider_by_name(
|
||
db: AsyncSession,
|
||
name: str,
|
||
added_by_user_id: int,
|
||
) -> tuple:
|
||
"""
|
||
Keres vagy létrehoz egy ServiceProvider-t a megadott név alapján.
|
||
|
||
Ez a függvény az expense auto-discovery hook része. Amikor egy user
|
||
költséget rögzít és megad egy external_vendor_name-t, ez a függvény
|
||
megkeresi, hogy létezik-e már a provider, és ha nem, létrehozza.
|
||
|
||
Logika:
|
||
1. Pontos match keresése ServiceProvider.name alapján (kisbetűs, space-sztrippelt)
|
||
2. Ha nem létezik → új provider létrehozása PENDING státusszal,
|
||
validation_score=10, source=import, vissza: PROVIDER_DISCOVERY
|
||
3. Ha létezik és APPROVED → vissza: PROVIDER_VERIFIED_USE
|
||
4. Ha létezik és nem APPROVED → vissza: PROVIDER_CONFIRMATION
|
||
5. A függvény meghívja a _award_provider_points()-t a megfelelő action_key-kel
|
||
|
||
Args:
|
||
db: AsyncSession
|
||
name: A provider neve (external_vendor_name)
|
||
added_by_user_id: A költséget rögzítő user ID-ja
|
||
|
||
Returns:
|
||
tuple[ServiceProvider, str]: (provider, action_key)
|
||
- action_key: "PROVIDER_DISCOVERY" | "PROVIDER_CONFIRMATION" | "PROVIDER_VERIFIED_USE"
|
||
"""
|
||
# 1. Pontos match keresése (kisbetűsen, space-sztrippelten)
|
||
clean_name = name.strip()
|
||
provider_stmt = select(ServiceProvider).where(
|
||
func.lower(ServiceProvider.name) == func.lower(clean_name)
|
||
)
|
||
provider_result = await db.execute(provider_stmt)
|
||
provider = provider_result.scalar_one_or_none()
|
||
|
||
if not provider:
|
||
# 2. Ha nem létezik → létrehozás + PROVIDER_DISCOVERY
|
||
new_provider = ServiceProvider(
|
||
name=clean_name,
|
||
address=clean_name,
|
||
status=ModerationStatus.pending,
|
||
source=SourceType.api,
|
||
validation_score=10,
|
||
added_by_user_id=added_by_user_id,
|
||
)
|
||
db.add(new_provider)
|
||
await db.flush()
|
||
|
||
logger.info(
|
||
f"Új provider felfedezve: name='{clean_name}', "
|
||
f"provider_id={new_provider.id}, user_id={added_by_user_id}"
|
||
)
|
||
|
||
# Gamification pont kiosztása
|
||
await _award_provider_points(
|
||
db=db,
|
||
user_id=added_by_user_id,
|
||
action_key="PROVIDER_DISCOVERY",
|
||
)
|
||
|
||
return new_provider, "PROVIDER_DISCOVERY"
|
||
|
||
# 3. Ha létezik, státusz alapján döntés
|
||
# P0 CRITICAL (Card #362): USE_UNVERIFIED_PROVIDER vs PROVIDER_CONFIRMATION
|
||
# - Ha a provider APPROVED -> PROVIDER_VERIFIED_USE (100 XP)
|
||
# - Ha a provider PENDING es a hasznalo user a provider letrehozoja -> PROVIDER_CONFIRMATION (150 XP)
|
||
# - Ha a provider PENDING es a hasznalo user NEM a provider letrehozoja -> USE_UNVERIFIED_PROVIDER (200 XP)
|
||
# Ez megakadalyozza az XP farmingot (sajat provider hasznalata sajat maganak).
|
||
if provider.status == ModerationStatus.approved:
|
||
action_key = "PROVIDER_VERIFIED_USE"
|
||
elif provider.added_by_user_id == added_by_user_id:
|
||
# A provider letrehozoja hasznalja a sajat pending provideret
|
||
action_key = "PROVIDER_CONFIRMATION"
|
||
else:
|
||
# Mas user hasznal egy pending providert
|
||
action_key = "USE_UNVERIFIED_PROVIDER"
|
||
|
||
logger.info(
|
||
f"Meglevo provider hasznalata: name='{clean_name}', "
|
||
f"provider_id={provider.id}, action_key='{action_key}', "
|
||
f"user_id={added_by_user_id}, provider_creator_id={provider.added_by_user_id}"
|
||
)
|
||
|
||
# Gamification pont kiosztása
|
||
await _award_provider_points(
|
||
db=db,
|
||
user_id=added_by_user_id,
|
||
action_key=action_key,
|
||
)
|
||
|
||
return provider, action_key
|
||
|
||
|
||
async def search_providers(
|
||
db: AsyncSession,
|
||
q: Optional[str] = None,
|
||
category: Optional[str] = None,
|
||
city: Optional[str] = None,
|
||
category_ids: Optional[List[int]] = None,
|
||
org_type: Optional[str] = None,
|
||
limit: int = 20,
|
||
offset: int = 0,
|
||
) -> ProviderSearchResponse:
|
||
"""
|
||
Egyesített szolgáltató keresés három forrásból:
|
||
1. fleet.organizations (verified orgs with org_type='service_provider')
|
||
2. marketplace.service_staging (robot által gyűjtött adatok)
|
||
3. marketplace.service_providers (crowdsourced adatok)
|
||
|
||
P1 CRITICAL ALIGN (2026-06-17): Az org lekérdezés most már visszaadja
|
||
az address_street_name, address_street_type, address_house_number mezőket is.
|
||
|
||
FEATURE (2026-06-17): category_ids szűrő — SZIGORÚ AND KAPCSOLAT.
|
||
- Ha több kategória ID van megadva, a szolgáltatónak MINDEGYIKHEZ
|
||
tartoznia kell (AND), nem elég, ha csak az egyikhez (OR).
|
||
- Nincs hierarchikus terjeszkedés: csak a pontosan megadott ID-kat
|
||
ellenőrizzük a ServiceExpertise táblában.
|
||
|
||
FEATURE (2026-06-17): Keresés szigorítása.
|
||
- A q (free-text) keresés csak a name és aliases mezőkben keres (ILIKE),
|
||
a tags mezőt NEM vizsgálja.
|
||
- A city keresés exact match vagy StartsWith, nem ILIKE.
|
||
|
||
FEATURE (2026-06-17): Kategória adatok visszaadása.
|
||
- Minden ProviderSearchResult tartalmazza a kapcsolódó kategóriák
|
||
(ExpertiseTag) ID-ját, nevét és szintjét a categories mezőben.
|
||
|
||
Args:
|
||
db: AsyncSession
|
||
q: Keresőszó (tokenizálva, AND kapcsolat) — opcionális
|
||
category: Kategória szűrő
|
||
city: Város szűrő — AND kapcsolat a q-val
|
||
category_ids: Kategória ID-k listája (SZIGORÚ AND szűrés)
|
||
org_type: Szervezet típus szűrő (pl. 'service_provider', 'insurance')
|
||
limit: Lapozási limit
|
||
offset: Lapozási offset
|
||
|
||
Returns:
|
||
ProviderSearchResponse lapozott találatokkal
|
||
"""
|
||
results = []
|
||
total = 0
|
||
|
||
# Tokenizáljuk a q paramétert: szóközök mentén feldaraboljuk
|
||
q_tokens: list[str] = []
|
||
if q:
|
||
q_tokens = [token.strip() for token in q.split() if token.strip()]
|
||
|
||
# --- 1. LEKÉRDEZÉS: Szervezetek (fleet.organizations) ---
|
||
org_conditions = [
|
||
Organization.is_deleted == False,
|
||
]
|
||
if org_type:
|
||
org_conditions.append(Organization.org_type == org_type)
|
||
else:
|
||
org_conditions.append(Organization.org_type == OrgType.service_provider)
|
||
if q_tokens:
|
||
token_conditions = []
|
||
for token in q_tokens:
|
||
token_conditions.append(
|
||
or_(
|
||
Organization.name.ilike(f"%{token}%"),
|
||
cast(Organization.aliases, Text).ilike(f"%{token}%"),
|
||
)
|
||
)
|
||
org_conditions.append(and_(*token_conditions))
|
||
if city:
|
||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||
city_clean = city.strip()
|
||
org_conditions.append(
|
||
or_(
|
||
Organization.address_city == city_clean,
|
||
Organization.address_city.ilike(f"{city_clean}%"),
|
||
Organization.address_zip == city_clean,
|
||
Organization.address_zip.ilike(f"{city_clean}%"),
|
||
)
|
||
)
|
||
|
||
# FEATURE (2026-06-17): Kategória szűrés category_ids alapján.
|
||
# SZIGORÚ AND KAPCSOLAT (2026-06-17): Ha több kategória ID van megadva,
|
||
# a szolgáltatónak MINDEGYIKHEZ tartoznia kell. Nincs hierarchikus
|
||
# terjeszkedés (path LIKE), csak a pontosan megadott ID-kat ellenőrizzük.
|
||
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids None,
|
||
# nem iterálunk rajta.
|
||
safe_category_ids = category_ids or []
|
||
if safe_category_ids:
|
||
# Minden egyes megadott category_id-hoz külön subquery-t készítünk,
|
||
# és AND kapcsolattal fűzzük össze őket. Ez biztosítja, hogy a
|
||
# szolgáltató minden kategóriával rendelkezzen.
|
||
for cid in safe_category_ids:
|
||
profile_subq = (
|
||
select(ServiceExpertise.service_id)
|
||
.where(ServiceExpertise.expertise_id == cid)
|
||
.subquery()
|
||
)
|
||
org_conditions.append(
|
||
ServiceProfile.id.in_(select(profile_subq.c.service_id))
|
||
)
|
||
|
||
# P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált
|
||
# címmezőket (address_street_name, address_street_type, address_house_number).
|
||
# Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán
|
||
# ne duplikálódjon a városnév (pl. "Dunakeszi, Dunakeszi").
|
||
org_stmt = select(
|
||
Organization.id.label("id"),
|
||
Organization.name.label("name"),
|
||
Organization.address_city.label("city"),
|
||
func.concat(
|
||
Organization.address_zip,
|
||
literal(' '),
|
||
Organization.address_city,
|
||
literal(', '),
|
||
Organization.address_street_name,
|
||
literal(' '),
|
||
Organization.address_street_type,
|
||
literal(' '),
|
||
Organization.address_house_number,
|
||
).label("address"),
|
||
Organization.address_zip.label("address_zip"),
|
||
Organization.address_street_name.label("address_street_name"),
|
||
Organization.address_street_type.label("address_street_type"),
|
||
Organization.address_house_number.label("address_house_number"),
|
||
Organization.plus_code.label("plus_code"),
|
||
Organization.is_verified.label("is_verified"),
|
||
ServiceProfile.contact_phone.label("contact_phone"),
|
||
ServiceProfile.contact_email.label("contact_email"),
|
||
ServiceProfile.website.label("website"),
|
||
ServiceProfile.specialization_tags.label("specialization_tags"),
|
||
case(
|
||
(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")),
|
||
else_=literal("verified_org"),
|
||
).label("source"),
|
||
).outerjoin(
|
||
ServiceProfile,
|
||
ServiceProfile.organization_id == Organization.id,
|
||
).where(*org_conditions)
|
||
|
||
# --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) ---
|
||
staging_conditions = []
|
||
if q_tokens:
|
||
token_conditions = []
|
||
for token in q_tokens:
|
||
token_conditions.append(
|
||
ServiceStaging.name.ilike(f"%{token}%")
|
||
)
|
||
staging_conditions.append(and_(*token_conditions))
|
||
if city:
|
||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||
city_clean = city.strip()
|
||
staging_conditions.append(
|
||
or_(
|
||
ServiceStaging.city == city_clean,
|
||
ServiceStaging.city.ilike(f"{city_clean}%"),
|
||
ServiceStaging.postal_code == city_clean,
|
||
ServiceStaging.postal_code.ilike(f"{city_clean}%"),
|
||
)
|
||
)
|
||
|
||
staging_stmt = select(
|
||
ServiceStaging.id.label("id"),
|
||
ServiceStaging.name.label("name"),
|
||
ServiceStaging.city.label("city"),
|
||
ServiceStaging.full_address.label("address"),
|
||
literal(None).label("address_zip"),
|
||
literal(None).label("address_street_name"),
|
||
literal(None).label("address_street_type"),
|
||
literal(None).label("address_house_number"),
|
||
literal(None).label("plus_code"),
|
||
literal(False).label("is_verified"),
|
||
literal(None).label("contact_phone"),
|
||
literal(None).label("contact_email"),
|
||
literal(None).label("website"),
|
||
literal(None).label("specialization_tags"),
|
||
literal("staged_data").label("source"),
|
||
)
|
||
if staging_conditions:
|
||
staging_stmt = staging_stmt.where(*staging_conditions)
|
||
|
||
# --- 3. LEKÉRDEZÉS: Crowdsourced adatok (marketplace.service_providers) ---
|
||
crowd_conditions = []
|
||
if q_tokens:
|
||
token_conditions = []
|
||
for token in q_tokens:
|
||
token_conditions.append(
|
||
ServiceProvider.name.ilike(f"%{token}%")
|
||
)
|
||
crowd_conditions.append(and_(*token_conditions))
|
||
if city:
|
||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||
city_clean = city.strip()
|
||
crowd_conditions.append(
|
||
or_(
|
||
ServiceProvider.address == city_clean,
|
||
ServiceProvider.address.ilike(f"{city_clean}%"),
|
||
)
|
||
)
|
||
|
||
crowd_stmt = select(
|
||
ServiceProvider.id.label("id"),
|
||
ServiceProvider.name.label("name"),
|
||
literal(None).label("city"),
|
||
ServiceProvider.address.label("address"),
|
||
literal(None).label("address_zip"),
|
||
literal(None).label("address_street_name"),
|
||
literal(None).label("address_street_type"),
|
||
literal(None).label("address_house_number"),
|
||
literal(None).label("plus_code"),
|
||
literal(False).label("is_verified"),
|
||
literal(None).label("contact_phone"),
|
||
literal(None).label("contact_email"),
|
||
literal(None).label("website"),
|
||
literal(None).label("specialization_tags"),
|
||
literal("crowd_added").label("source"),
|
||
)
|
||
if crowd_conditions:
|
||
crowd_stmt = crowd_stmt.where(*crowd_conditions)
|
||
|
||
# --- UNION ---
|
||
union_parts = [org_stmt]
|
||
union_parts.append(staging_stmt)
|
||
union_parts.append(crowd_stmt)
|
||
|
||
# UNION végrehajtása
|
||
if len(union_parts) == 1:
|
||
base_query = union_parts[0]
|
||
else:
|
||
base_query = union_all(*union_parts)
|
||
|
||
# Teljes darabszám
|
||
count_subquery = base_query.subquery()
|
||
count_stmt = select(func.count()).select_from(count_subquery)
|
||
total_result = await db.execute(count_stmt)
|
||
total = total_result.scalar() or 0
|
||
|
||
# Lapozott lekérdezés
|
||
paginated_query = base_query.order_by("source").offset(offset).limit(limit)
|
||
result = await db.execute(paginated_query)
|
||
rows = result.fetchall()
|
||
|
||
# FEATURE (2026-06-17): Kategória adatok előtöltése.
|
||
# Lekérdezzük az összes olyan ServiceProfile-hoz tartozó ExpertiseTag-eket,
|
||
# amelyek a találati listában szereplő organization ID-khoz tartoznak.
|
||
# Ezt egyetlen batch lekérdezéssel tesszük a N+1 probléma elkerülése érdekében.
|
||
org_ids = [row.id for row in rows if row.source in ("verified_org", "crowd_added")]
|
||
org_categories_map: dict[int, list[dict]] = {}
|
||
if org_ids:
|
||
cat_stmt = (
|
||
select(
|
||
ServiceProfile.organization_id,
|
||
ExpertiseTag.id,
|
||
ExpertiseTag.name_hu,
|
||
ExpertiseTag.name_en,
|
||
ExpertiseTag.level,
|
||
ExpertiseTag.key,
|
||
)
|
||
.select_from(ServiceExpertise)
|
||
.join(ExpertiseTag, ServiceExpertise.expertise_id == ExpertiseTag.id)
|
||
.join(ServiceProfile, ServiceExpertise.service_id == ServiceProfile.id)
|
||
.where(
|
||
ServiceProfile.organization_id.in_(org_ids),
|
||
)
|
||
)
|
||
cat_result = await db.execute(cat_stmt)
|
||
cat_rows = cat_result.fetchall()
|
||
for cat_row in cat_rows:
|
||
org_id = cat_row.organization_id
|
||
if org_id not in org_categories_map:
|
||
org_categories_map[org_id] = []
|
||
org_categories_map[org_id].append({
|
||
"id": cat_row.id,
|
||
"name_hu": cat_row.name_hu,
|
||
"name_en": cat_row.name_en,
|
||
"level": cat_row.level,
|
||
"key": cat_row.key,
|
||
})
|
||
|
||
for row in rows:
|
||
tags_list: list = []
|
||
spec_tags = getattr(row, "specialization_tags", None)
|
||
if spec_tags and isinstance(spec_tags, dict):
|
||
user_tags = spec_tags.get("user_tags", [])
|
||
if user_tags:
|
||
tags_list = list(user_tags)
|
||
|
||
# Kategória adatok összeállítása
|
||
row_categories = org_categories_map.get(row.id, [])
|
||
categories_out = [
|
||
CategoryInfo(
|
||
id=cat["id"],
|
||
name_hu=cat["name_hu"],
|
||
name_en=cat["name_en"],
|
||
level=cat["level"],
|
||
key=cat["key"],
|
||
)
|
||
for cat in row_categories
|
||
]
|
||
|
||
results.append(
|
||
ProviderSearchResult(
|
||
id=row.id,
|
||
name=row.name,
|
||
city=row.city,
|
||
address=row.address,
|
||
address_zip=getattr(row, "address_zip", None),
|
||
address_street_name=getattr(row, "address_street_name", None),
|
||
address_street_type=getattr(row, "address_street_type", None),
|
||
address_house_number=getattr(row, "address_house_number", None),
|
||
plus_code=getattr(row, "plus_code", None),
|
||
contact_phone=getattr(row, "contact_phone", None),
|
||
contact_email=getattr(row, "contact_email", None),
|
||
website=getattr(row, "website", None),
|
||
tags=tags_list,
|
||
categories=categories_out,
|
||
source=row.source,
|
||
is_verified=row.is_verified,
|
||
)
|
||
)
|
||
|
||
page = (offset // limit) + 1 if limit > 0 else 1
|
||
|
||
return ProviderSearchResponse(
|
||
results=results,
|
||
total=total,
|
||
page=page,
|
||
per_page=limit,
|
||
)
|
||
|
||
|
||
async def quick_add_provider(
|
||
db: AsyncSession,
|
||
data: ProviderQuickAddIn,
|
||
user_id: int,
|
||
) -> ProviderQuickAddResponse:
|
||
"""
|
||
Gyors szolgáltató felvétel crowdsourcingból.
|
||
|
||
P0 HYBRID VENDOR REFACTOR (2026-06-25):
|
||
========================================
|
||
A quick_add_provider() MOST MÁR ServiceProvider-t hoz létre, NEM Organization-t.
|
||
Ez a P0 Architecture Refactor (Option C - Hybrid) része.
|
||
|
||
Új folyamat:
|
||
1. (Opcionális) Elsődleges kategória ellenőrzése (expertise_tags)
|
||
2. ServiceProvider létrehozása (marketplace.service_providers)
|
||
3. ServiceProfile létrehozása (service_provider_id hivatkozással)
|
||
4. ServiceExpertise kapcsolatok létrehozása (category_id + category_ids + new_tags)
|
||
5. 🎮 Gamification pontozás
|
||
|
||
NINCS: Organization, Branch, OrganizationMember létrehozás!
|
||
|
||
Args:
|
||
db: AsyncSession
|
||
data: ProviderQuickAddIn
|
||
user_id: A felhasználó ID-ja
|
||
|
||
Returns:
|
||
ProviderQuickAddResponse (id = ServiceProvider.id)
|
||
|
||
Raises:
|
||
ValueError: Ha a kategória nem létezik
|
||
"""
|
||
# 1. (Opcionális) Elsődleges kategória ellenőrzése
|
||
primary_category_key = None
|
||
if data.category_id is not None:
|
||
category = await db.get(ExpertiseTag, data.category_id)
|
||
if not category:
|
||
raise ValueError(f"Category with id={data.category_id} not found")
|
||
primary_category_key = category.key
|
||
|
||
# 2. ServiceProvider létrehozása (NEM Organization!)
|
||
provider = ServiceProvider(
|
||
name=data.name,
|
||
address=", ".join(filter(None, [
|
||
data.address_street_name or "",
|
||
data.address_street_type or "",
|
||
data.address_house_number or "",
|
||
])) or data.name,
|
||
category=primary_category_key,
|
||
# Atomizált címmezők
|
||
city=data.city,
|
||
address_zip=data.address_zip,
|
||
address_street_name=data.address_street_name,
|
||
address_street_type=data.address_street_type,
|
||
address_house_number=data.address_house_number,
|
||
plus_code=data.plus_code,
|
||
# Kapcsolatfelvételi adatok
|
||
contact_phone=data.contact_phone,
|
||
contact_email=data.contact_email,
|
||
website=data.website,
|
||
# Státusz és forrás
|
||
status="pending",
|
||
source="manual",
|
||
validation_score=50,
|
||
added_by_user_id=user_id,
|
||
)
|
||
db.add(provider)
|
||
await db.flush()
|
||
|
||
# 3. ServiceProfile létrehozása (service_provider_id hivatkozással)
|
||
fingerprint = hashlib.sha256(
|
||
f"quick-add-{provider.id}-{datetime.now().isoformat()}".encode()
|
||
).hexdigest()[:64]
|
||
|
||
# specialization_tags összeállítása: primary_category + user-supplied tags
|
||
spec_tags: dict = {}
|
||
if primary_category_key:
|
||
spec_tags["primary_category"] = primary_category_key
|
||
if data.tags:
|
||
spec_tags["user_tags"] = data.tags
|
||
|
||
profile = ServiceProfile(
|
||
service_provider_id=provider.id,
|
||
fingerprint=fingerprint,
|
||
status="ghost",
|
||
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
|
||
specialization_tags=spec_tags,
|
||
contact_phone=data.contact_phone,
|
||
contact_email=data.contact_email,
|
||
website=data.website,
|
||
)
|
||
db.add(profile)
|
||
await db.flush()
|
||
|
||
# =====================================================================
|
||
# 4. 🏗️ ServiceExpertise kapcsolatok létrehozása (4-LEVEL CATEGORY)
|
||
# =====================================================================
|
||
all_category_ids: List[int] = []
|
||
|
||
# 4a. Elsődleges kategória (ha meg van adva)
|
||
if data.category_id is not None:
|
||
all_category_ids.append(data.category_id)
|
||
|
||
# 4b. category_ids a 4-level checkboxokból
|
||
safe_category_ids = data.category_ids or []
|
||
if safe_category_ids:
|
||
all_category_ids.extend(safe_category_ids)
|
||
|
||
# 4c. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
|
||
safe_new_tags = data.new_tags or []
|
||
new_tag_ids: List[int] = []
|
||
if safe_new_tags:
|
||
new_tag_ids = await _create_new_tags(
|
||
db=db,
|
||
service_profile_id=profile.id,
|
||
new_tag_names=safe_new_tags,
|
||
)
|
||
if new_tag_ids:
|
||
all_category_ids.extend(new_tag_ids)
|
||
|
||
# 4d. ServiceExpertise kapcsolatok létrehozása
|
||
if all_category_ids:
|
||
unique_ids = list(set(all_category_ids))
|
||
for expertise_id in unique_ids:
|
||
expertise = ServiceExpertise(
|
||
service_id=profile.id,
|
||
expertise_id=expertise_id,
|
||
confidence_level=50,
|
||
)
|
||
db.add(expertise)
|
||
logger.info(
|
||
f"ServiceExpertise kapcsolatok létrehozva: service_id={profile.id}, "
|
||
f"expertise_ids={unique_ids}"
|
||
)
|
||
# =====================================================================
|
||
|
||
# =====================================================================
|
||
# 5. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás)
|
||
# =====================================================================
|
||
earned_points = await _award_provider_points(
|
||
db=db,
|
||
user_id=user_id,
|
||
action_key="ADD_NEW_PROVIDER",
|
||
)
|
||
# =====================================================================
|
||
|
||
await db.commit()
|
||
await db.refresh(provider)
|
||
|
||
logger.info(
|
||
f"Quick-add provider created: service_provider_id={provider.id}, name={data.name}, "
|
||
f"user_id={user_id}, earned_points={earned_points}"
|
||
)
|
||
|
||
return ProviderQuickAddResponse(
|
||
id=provider.id,
|
||
name=data.name,
|
||
status="pending_verification",
|
||
message="Szolgáltató sikeresen rögzítve. Ellenőrzés alatt.",
|
||
earned_points=earned_points,
|
||
)
|
||
|
||
|
||
async def _sync_service_expertises(
|
||
db: AsyncSession,
|
||
service_profile_id: int,
|
||
category_ids: List[int],
|
||
) -> None:
|
||
"""
|
||
Szinkronizálja a ServiceExpertise kapcsolatokat a megadott kategória ID-k alapján.
|
||
|
||
4-Level Category Hybrid Save (2026-06-17):
|
||
- Eltávolítja a meglévő kapcsolatokat, amelyek nincsenek az új listában
|
||
- Hozzáadja az új kapcsolatokat, amelyek még nem léteznek
|
||
|
||
Args:
|
||
db: AsyncSession
|
||
service_profile_id: A ServiceProfile ID-ja
|
||
category_ids: Az új kategória ID-k listája
|
||
"""
|
||
if not category_ids:
|
||
return
|
||
|
||
# Meglévő kapcsolatok lekérése
|
||
existing_stmt = select(ServiceExpertise).where(
|
||
ServiceExpertise.service_id == service_profile_id
|
||
)
|
||
existing_result = await db.execute(existing_stmt)
|
||
existing_expertises = existing_result.scalars().all()
|
||
existing_ids = {e.expertise_id for e in existing_expertises}
|
||
|
||
# Eltávolítandó kapcsolatok (amik nincsenek az új listában)
|
||
ids_to_remove = existing_ids - set(category_ids)
|
||
if ids_to_remove:
|
||
delete_stmt = delete(ServiceExpertise).where(
|
||
ServiceExpertise.service_id == service_profile_id,
|
||
ServiceExpertise.expertise_id.in_(ids_to_remove),
|
||
)
|
||
await db.execute(delete_stmt)
|
||
logger.info(
|
||
f"Eltávolított ServiceExpertise kapcsolatok: service_id={service_profile_id}, "
|
||
f"expertise_ids={ids_to_remove}"
|
||
)
|
||
|
||
# Hozzáadandó kapcsolatok (amik újak)
|
||
ids_to_add = set(category_ids) - existing_ids
|
||
for expertise_id in ids_to_add:
|
||
new_expertise = ServiceExpertise(
|
||
service_id=service_profile_id,
|
||
expertise_id=expertise_id,
|
||
confidence_level=50,
|
||
)
|
||
db.add(new_expertise)
|
||
logger.info(
|
||
f"Új ServiceExpertise kapcsolat: service_id={service_profile_id}, "
|
||
f"expertise_id={expertise_id}"
|
||
)
|
||
|
||
|
||
def _slugify(text: str) -> str:
|
||
"""
|
||
Biztonságos slug létrehozása magyar ékezetes karakterekkel.
|
||
|
||
- Kisbetűsítés
|
||
- Ékezetes karakterek cseréje (á→a, é→e, í→i, ó→o, ö→o, ő→o, ú→u, ü→u, ű→u)
|
||
- Nem alfanumerikus karakterek cseréje alulvonásra
|
||
- Maximum 50 karakter
|
||
"""
|
||
replacements = {
|
||
'á': 'a', 'é': 'e', 'í': 'i', 'ó': 'o', 'ö': 'o', 'ő': 'o',
|
||
'ú': 'u', 'ü': 'u', 'ű': 'u',
|
||
'Á': 'a', 'É': 'e', 'Í': 'i', 'Ó': 'o', 'Ö': 'o', 'Ő': 'o',
|
||
'Ú': 'u', 'Ü': 'u', 'Ű': 'u',
|
||
}
|
||
result = text.lower().strip()
|
||
for hungarian, ascii_char in replacements.items():
|
||
result = result.replace(hungarian, ascii_char)
|
||
# Replace any non-alphanumeric character (except underscore) with underscore
|
||
result = re.sub(r'[^a-z0-9_]', '_', result)
|
||
# Collapse multiple underscores
|
||
result = re.sub(r'_+', '_', result)
|
||
# Strip leading/trailing underscores
|
||
result = result.strip('_')
|
||
return result[:50]
|
||
|
||
|
||
async def _create_new_tags(
|
||
db: AsyncSession,
|
||
service_profile_id: int,
|
||
new_tag_names: List[str],
|
||
) -> List[int]:
|
||
"""
|
||
Létrehoz új ExpertiseTag rekordokat a user által gépelt címkékből.
|
||
|
||
P0 CRITICAL BUGFIX (2026-06-17): try-except blokk + slugify + hibanaplózás.
|
||
|
||
4-Level Category Hybrid Save (2026-06-17):
|
||
- Ha a címke string már létezik az ExpertiseTag táblában, visszaadja az ID-ját
|
||
- Ha a címke vadiúj, létrehozza is_official=False és level=3 értékekkel
|
||
|
||
Args:
|
||
db: AsyncSession
|
||
service_profile_id: A ServiceProfile ID-ja (a kapcsolathoz)
|
||
new_tag_names: A user által gépelt új címkenevek listája
|
||
|
||
Returns:
|
||
List[int]: A létrehozott/megtalált ExpertiseTag ID-k listája
|
||
"""
|
||
if not new_tag_names:
|
||
return []
|
||
|
||
created_ids = []
|
||
|
||
for tag_name in new_tag_names:
|
||
tag_name = tag_name.strip()
|
||
if not tag_name:
|
||
continue
|
||
|
||
try:
|
||
# Ellenőrizzük, hogy létezik-e már
|
||
existing_stmt = select(ExpertiseTag).where(
|
||
or_(
|
||
ExpertiseTag.key == _slugify(tag_name),
|
||
ExpertiseTag.name_hu == tag_name,
|
||
ExpertiseTag.name_en == tag_name,
|
||
)
|
||
)
|
||
existing_result = await db.execute(existing_stmt)
|
||
existing_tag = existing_result.scalar_one_or_none()
|
||
|
||
if existing_tag:
|
||
# Már létezik, használjuk a meglévő ID-t
|
||
created_ids.append(existing_tag.id)
|
||
logger.info(
|
||
f"Meglévő címke használata: tag_name={tag_name}, "
|
||
f"existing_id={existing_tag.id}"
|
||
)
|
||
else:
|
||
# Vadiúj címke létrehozása slugify-jal
|
||
new_key = _slugify(tag_name)
|
||
new_tag = ExpertiseTag(
|
||
key=new_key,
|
||
name_hu=tag_name,
|
||
name_en=tag_name,
|
||
level=3,
|
||
is_official=False,
|
||
category="user_created",
|
||
parent_id=None,
|
||
path=None,
|
||
search_keywords=[tag_name.lower()],
|
||
description=f"User-created tag: {tag_name}",
|
||
)
|
||
db.add(new_tag)
|
||
await db.flush()
|
||
created_ids.append(new_tag.id)
|
||
logger.info(
|
||
f"Új címke létrehozva: tag_name={tag_name}, "
|
||
f"new_id={new_tag.id}, key={new_key}"
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f"HIBA az új címke létrehozásakor: tag_name={tag_name}, "
|
||
f"error={e}", exc_info=True
|
||
)
|
||
# Nem szakítjuk meg a teljes folyamatot egyetlen hibás címke miatt
|
||
continue
|
||
|
||
return created_ids
|
||
|
||
|
||
async def update_provider(
|
||
db: AsyncSession,
|
||
provider_id: int,
|
||
data: ProviderUpdateIn,
|
||
user_id: int,
|
||
) -> ProviderUpdateResponse:
|
||
"""
|
||
Szolgáltató adatainak szerkesztése.
|
||
|
||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||
- data.street -> data.address_street_name, data.address_street_type, data.address_house_number
|
||
- A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek.
|
||
|
||
4-Level Category Hybrid Save (2026-06-17):
|
||
- category_ids: ServiceExpertise kapcsolatok frissítése
|
||
- new_tags: Új ExpertiseTag létrehozása (is_official=False, level=3)
|
||
majd ServiceExpertise kapcsolat létrehozása
|
||
|
||
Multi-source update (2026-06-17): Támogatja mindhárom forrást:
|
||
1. fleet.organizations (verified orgs) - közvetlen frissítés
|
||
2. marketplace.service_staging (robot adatok) - migrálás Organization-be
|
||
3. marketplace.service_providers (crowdsourced) - migrálás Organization-be
|
||
|
||
Frissíti az Organization és a ServiceProfile rekordokat a megadott
|
||
adatokkal. A forrást (source) a backend állítja be, a frontend SOHA
|
||
nem küldheti azt!
|
||
|
||
Args:
|
||
db: AsyncSession
|
||
provider_id: A szolgáltató ID-ja
|
||
data: ProviderUpdateIn séma
|
||
user_id: A szerkesztő felhasználó ID-ja
|
||
|
||
Returns:
|
||
ProviderUpdateResponse
|
||
|
||
Raises:
|
||
ValueError: Ha a szolgáltató nem található
|
||
PermissionError: Ha a felhasználó nem tagja a szervezetnek
|
||
"""
|
||
# 1. Organization lekérdezése
|
||
org = await db.get(Organization, provider_id)
|
||
|
||
if org:
|
||
# =====================================================================
|
||
# 1a. 🔐 ACCESS CONTROL ELLENŐRZÉS (P0 CRITICAL BUGFIX 2026-06-17)
|
||
# =====================================================================
|
||
# Csak azok a felhasználók szerkeszthetik a szolgáltatót, akik
|
||
# OWNER vagy ADMIN szerepkörű tagok a szervezetben.
|
||
member_stmt = select(OrganizationMember).where(
|
||
OrganizationMember.organization_id == org.id,
|
||
OrganizationMember.user_id == user_id,
|
||
OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN]),
|
||
)
|
||
member_result = await db.execute(member_stmt)
|
||
member = member_result.scalar_one_or_none()
|
||
|
||
if not member:
|
||
logger.warning(
|
||
f"Access denied: user_id={user_id} attempted to update "
|
||
f"org_id={org.id} but is not an OWNER or ADMIN member"
|
||
)
|
||
raise PermissionError(
|
||
f"Nincs jogosultságod szerkeszteni ezt a szolgáltatót. "
|
||
f"Csak a szervezet tulajdonosa vagy adminisztrátora "
|
||
f"módosíthatja az adatokat."
|
||
)
|
||
# =====================================================================
|
||
|
||
if not org:
|
||
# 1b. Ha nincs Organization-ben, ServiceStaging-ben keresünk
|
||
staging = await db.get(ServiceStaging, provider_id)
|
||
if staging:
|
||
# Migráljuk Organization-be
|
||
now = datetime.now(timezone.utc)
|
||
org = Organization(
|
||
id=staging.id,
|
||
name=staging.name[:100],
|
||
full_name=staging.name,
|
||
display_name=staging.name[:50],
|
||
org_type=OrgType.service_provider,
|
||
address_city=staging.city,
|
||
address_zip=data.address_zip,
|
||
address_street_name=data.address_street_name,
|
||
address_street_type=data.address_street_type,
|
||
address_house_number=data.address_house_number,
|
||
status="pending_verification",
|
||
is_verified=False,
|
||
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||
first_registered_at=now,
|
||
current_lifecycle_started_at=now,
|
||
created_at=now,
|
||
subscription_plan="FREE",
|
||
base_asset_limit=1,
|
||
purchased_extra_slots=0,
|
||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||
external_integration_config={},
|
||
is_ownership_transferable=True,
|
||
)
|
||
db.add(org)
|
||
await db.flush()
|
||
logger.info(
|
||
f"Provider migrated from ServiceStaging: staging_id={provider_id}, "
|
||
f"new org_id={org.id}, user_id={user_id}"
|
||
)
|
||
else:
|
||
# 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk
|
||
crowd = await db.get(ServiceProvider, provider_id)
|
||
if crowd:
|
||
# Migráljuk Organization-be
|
||
now = datetime.now(timezone.utc)
|
||
org = Organization(
|
||
id=crowd.id,
|
||
name=crowd.name[:100],
|
||
full_name=crowd.name,
|
||
display_name=crowd.name[:50],
|
||
org_type=OrgType.service_provider,
|
||
address_city=data.city,
|
||
address_zip=data.address_zip,
|
||
address_street_name=data.address_street_name,
|
||
address_street_type=data.address_street_type,
|
||
address_house_number=data.address_house_number,
|
||
status="pending_verification",
|
||
is_verified=False,
|
||
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||
first_registered_at=now,
|
||
current_lifecycle_started_at=now,
|
||
created_at=now,
|
||
subscription_plan="FREE",
|
||
base_asset_limit=1,
|
||
purchased_extra_slots=0,
|
||
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||
external_integration_config={},
|
||
is_ownership_transferable=True,
|
||
)
|
||
db.add(org)
|
||
await db.flush()
|
||
logger.info(
|
||
f"Provider migrated from ServiceProvider: crowd_id={provider_id}, "
|
||
f"new org_id={org.id}, user_id={user_id}"
|
||
)
|
||
else:
|
||
# 1d. Ha egyikben sem található
|
||
raise ValueError(f"Provider with id={provider_id} not found")
|
||
|
||
# 2. Organization mezők frissítése atomizált címmezőkkel
|
||
# ADATVÉDELMI SZABÁLY (2026-06-17): Csak akkor írjuk felül a mezőt,
|
||
# ha a frontend explicit értéket küldött (nem None). Ez megakadályozza,
|
||
# hogy a meglévő címadatok véletlenül null-ra állítódjanak, amikor
|
||
# a felhasználó csak más mezőket szerkeszt.
|
||
org.name = data.name[:100]
|
||
org.full_name = data.name
|
||
org.display_name = data.name[:50]
|
||
if data.city is not None:
|
||
org.address_city = data.city
|
||
if data.address_zip is not None:
|
||
org.address_zip = data.address_zip
|
||
if data.address_street_name is not None:
|
||
org.address_street_name = data.address_street_name
|
||
if data.address_street_type is not None:
|
||
org.address_street_type = data.address_street_type
|
||
if data.address_house_number is not None:
|
||
org.address_house_number = data.address_house_number
|
||
if data.plus_code is not None:
|
||
org.plus_code = data.plus_code
|
||
|
||
# 3. ServiceProfile lekérdezése (ha létezik)
|
||
profile_stmt = select(ServiceProfile).where(
|
||
ServiceProfile.organization_id == org.id
|
||
)
|
||
profile_result = await db.execute(profile_stmt)
|
||
profile = profile_result.scalar_one_or_none()
|
||
|
||
if profile:
|
||
# 4. ServiceProfile mezők frissítése
|
||
profile.contact_phone = data.contact_phone
|
||
profile.contact_email = data.contact_email
|
||
profile.website = data.website
|
||
|
||
# specialization_tags frissítése
|
||
if data.tags is not None:
|
||
spec_tags = profile.specialization_tags or {}
|
||
spec_tags["user_tags"] = data.tags
|
||
profile.specialization_tags = spec_tags
|
||
|
||
# =====================================================================
|
||
# 5. 🏗️ 4-LEVEL CATEGORY HYBRID SAVE (2026-06-17)
|
||
# =====================================================================
|
||
|
||
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids vagy
|
||
# new_tags None, nem iterálunk rajtuk, és nem hívjuk meg az extend-et.
|
||
|
||
# Összegyűjtjük az összes kategória ID-t (meglévő + új)
|
||
all_category_ids: List[int] = []
|
||
|
||
# 5a. category_ids -> ServiceExpertise kapcsolatok frissítése
|
||
safe_category_ids = data.category_ids or []
|
||
if safe_category_ids:
|
||
all_category_ids.extend(safe_category_ids)
|
||
|
||
# 5b. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
|
||
safe_new_tags = data.new_tags or []
|
||
if safe_new_tags:
|
||
new_tag_ids = await _create_new_tags(
|
||
db=db,
|
||
service_profile_id=profile.id,
|
||
new_tag_names=safe_new_tags,
|
||
)
|
||
if new_tag_ids:
|
||
all_category_ids.extend(new_tag_ids)
|
||
|
||
# 5c. Egyszeri szinkronizálás az összes kategória ID-val
|
||
if all_category_ids:
|
||
await _sync_service_expertises(
|
||
db=db,
|
||
service_profile_id=profile.id,
|
||
category_ids=all_category_ids,
|
||
)
|
||
# =====================================================================
|
||
|
||
logger.info(
|
||
f"Provider update: org_id={org.id}, profile_id={profile.id}, "
|
||
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, "
|
||
f"website={data.website}, tags={data.tags}, "
|
||
f"category_ids={data.category_ids}, new_tags={data.new_tags}"
|
||
)
|
||
else:
|
||
# P0 CRITICAL BUGFIX (2026-06-17): Ha nincs ServiceProfile, létrehozzuk.
|
||
# Ez biztosítja, hogy a contact_phone, contact_email, website, tags
|
||
# és category_ids adatok mentésre kerüljenek, még akkor is, ha a
|
||
# szolgáltató korábban csak Organization-ként létezett.
|
||
logger.warning(
|
||
f"Provider update: org_id={org.id} has no ServiceProfile. "
|
||
f"Creating one now."
|
||
)
|
||
fingerprint = hashlib.sha256(
|
||
f"update-create-{org.id}-{datetime.now().isoformat()}".encode()
|
||
).hexdigest()[:64]
|
||
profile = ServiceProfile(
|
||
organization_id=org.id,
|
||
fingerprint=fingerprint,
|
||
location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326),
|
||
contact_phone=data.contact_phone,
|
||
contact_email=data.contact_email,
|
||
website=data.website,
|
||
specialization_tags={"user_tags": data.tags} if data.tags else {},
|
||
status=ServiceStatus.active,
|
||
)
|
||
db.add(profile)
|
||
await db.flush()
|
||
logger.info(
|
||
f"ServiceProfile created for org_id={org.id}, profile_id={profile.id}"
|
||
)
|
||
|
||
# 4. ServiceProfile mezők frissítése
|
||
profile.contact_phone = data.contact_phone
|
||
profile.contact_email = data.contact_email
|
||
profile.website = data.website
|
||
|
||
# specialization_tags frissítése
|
||
if data.tags is not None:
|
||
spec_tags = profile.specialization_tags or {}
|
||
spec_tags["user_tags"] = data.tags
|
||
profile.specialization_tags = spec_tags
|
||
|
||
# =====================================================================
|
||
# 5. 🏗️ 4-LEVEL CATEGORY HYBRID SAVE (2026-06-17)
|
||
# =====================================================================
|
||
|
||
# P0 CRITICAL BUGFIX (2026-06-17): Null-safety — ha category_ids vagy
|
||
# new_tags None, nem iterálunk rajtuk, és nem hívjuk meg az extend-et.
|
||
|
||
# Összegyűjtjük az összes kategória ID-t (meglévő + új)
|
||
all_category_ids: List[int] = []
|
||
|
||
# 5a. category_ids -> ServiceExpertise kapcsolatok frissítése
|
||
safe_category_ids = data.category_ids or []
|
||
if safe_category_ids:
|
||
all_category_ids.extend(safe_category_ids)
|
||
|
||
# 5b. new_tags -> Új ExpertiseTag létrehozása + kapcsolat
|
||
safe_new_tags = data.new_tags or []
|
||
if safe_new_tags:
|
||
new_tag_ids = await _create_new_tags(
|
||
db=db,
|
||
service_profile_id=profile.id,
|
||
new_tag_names=safe_new_tags,
|
||
)
|
||
if new_tag_ids:
|
||
all_category_ids.extend(new_tag_ids)
|
||
|
||
# 5c. Egyszeri szinkronizálás az összes kategória ID-val
|
||
if all_category_ids:
|
||
await _sync_service_expertises(
|
||
db=db,
|
||
service_profile_id=profile.id,
|
||
category_ids=all_category_ids,
|
||
)
|
||
# =====================================================================
|
||
|
||
logger.info(
|
||
f"Provider update: org_id={org.id}, profile_id={profile.id}, "
|
||
f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, "
|
||
f"website={data.website}, tags={data.tags}, "
|
||
f"category_ids={data.category_ids}, new_tags={data.new_tags}"
|
||
)
|
||
|
||
# =====================================================================
|
||
# 6. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás szerkesztésért)
|
||
# =====================================================================
|
||
# A felhasználó aktuális penalty_level-jétől (restriction_level) függően
|
||
# kalkuláljuk a kapott pontot. A GamificationService.process_activity()
|
||
# automatikusan alkalmazza a büntetési szorzót a _calculate_multiplier-en
|
||
# keresztül, így:
|
||
# - Szint 0 (L0) = 100% pont
|
||
# - Szint 1 (L1) = 50% pont
|
||
# - Szint 2 (L2) = 10% pont
|
||
# - Szint 3 (L3) = 0% pont (blokkolt)
|
||
earned_points = await _award_provider_points(
|
||
db=db,
|
||
user_id=user_id,
|
||
action_key="UPDATE_PROVIDER",
|
||
)
|
||
# =====================================================================
|
||
|
||
await db.commit()
|
||
|
||
logger.info(
|
||
f"Provider updated successfully: org_id={org.id}, "
|
||
f"name={data.name}, user_id={user_id}, earned_points={earned_points}"
|
||
)
|
||
|
||
return ProviderUpdateResponse(
|
||
id=org.id,
|
||
name=data.name,
|
||
message="Szolgáltató adatai sikeresen frissítve.",
|
||
earned_points=earned_points,
|
||
)
|