refaktor címtár

This commit is contained in:
Roo
2026-07-01 19:49:58 +00:00
parent 6e627d0ebe
commit 7654913d21
198 changed files with 3485 additions and 1609 deletions

View File

@@ -34,14 +34,16 @@ 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 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, 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,
@@ -52,6 +54,8 @@ from app.schemas.provider import (
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__)
@@ -126,17 +130,28 @@ async def find_or_create_provider_by_name(
"""
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 (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
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
@@ -144,25 +159,37 @@ async def find_or_create_provider_by_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"
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 (kisbetűsen, space-sztrippelten)
# 1. Pontos match keresése (ILIKE, kisbetűsen, space-sztrippelten)
clean_name = name.strip()
provider_stmt = select(ServiceProvider).where(
func.lower(ServiceProvider.name) == func.lower(clean_name)
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:
# 2. Ha nem létezik → létrehozás + PROVIDER_DISCOVERY
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,
validation_score=10,
source=SourceType.api_import, # "import" — API import forrás
validation_score=0,
added_by_user_id=added_by_user_id,
)
db.add(new_provider)
@@ -173,44 +200,16 @@ async def find_or_create_provider_by_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"
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}, action_key='{action_key}', "
f"provider_id={provider.id}, "
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
return provider, False
async def search_providers(
@@ -287,14 +286,16 @@ async def search_providers(
)
org_conditions.append(and_(*token_conditions))
if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
# 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 és irányítószám adatokat.
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}%"),
GeoPostalCode.city == city_clean,
GeoPostalCode.city.ilike(f"{city_clean}%"),
GeoPostalCode.zip_code == city_clean,
GeoPostalCode.zip_code.ilike(f"{city_clean}%"),
)
)
@@ -319,30 +320,30 @@ async def search_providers(
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").
# 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"),
Organization.address_city.label("city"),
GeoPostalCode.city.label("city"),
func.concat(
Organization.address_zip,
GeoPostalCode.zip_code,
literal(' '),
Organization.address_city,
GeoPostalCode.city,
literal(', '),
Organization.address_street_name,
Address.street_name,
literal(' '),
Organization.address_street_type,
Address.street_type,
literal(' '),
Organization.address_house_number,
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"),
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"),
@@ -355,6 +356,12 @@ async def search_providers(
).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) ---
@@ -517,17 +524,36 @@ async def search_providers(
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)
address_detail = None
if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]):
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,
)
results.append(
ProviderSearchResult(
id=row.id,
name=row.name,
city=row.city,
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),
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),
@@ -535,6 +561,7 @@ async def search_providers(
categories=categories_out,
source=row.source,
is_verified=row.is_verified,
address_detail=address_detail,
)
)
@@ -954,17 +981,23 @@ async def update_provider(
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_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,
address_id=staging_address_id,
status="pending_verification",
is_verified=False,
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
@@ -988,6 +1021,16 @@ async def update_provider(
# 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(
@@ -996,11 +1039,7 @@ async def update_provider(
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,
address_id=crowd_address_id,
status="pending_verification",
is_verified=False,
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
@@ -1024,26 +1063,41 @@ async def update_provider(
# 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.
# 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:
org.address_city = data.city
addr_fields["city"] = data.city
if data.address_zip is not None:
org.address_zip = data.address_zip
addr_fields["zip"] = data.address_zip
if data.address_street_name is not None:
org.address_street_name = data.address_street_name
addr_fields["street_name"] = data.address_street_name
if data.address_street_type is not None:
org.address_street_type = data.address_street_type
addr_fields["street_type"] = data.address_street_type
if data.address_house_number is not None:
org.address_house_number = data.address_house_number
addr_fields["house_number"] = data.address_house_number
if data.plus_code is not None:
org.plus_code = data.plus_code
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(