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

@@ -0,0 +1,267 @@
"""
Unified AddressManager Service (P0 Implementation).
Centralizes ALL address logic into a single class-based service.
No more direct denormalized field writes in API endpoints.
Methods:
AddressManager.resolve(db, address_data) -> UUID
Checks if the address exists (or if it's a duplicate),
resolves GeoPostalCode, and returns the address_id.
AddressManager.create_or_update(db, address_id, data) -> UUID
Atomic operation to create/update system.addresses and return the ID.
AddressManager.get_normalized(db, address_id) -> dict
Returns the full address object including postal code details.
Usage:
from app.services.address_manager import AddressManager
new_address_id = await AddressManager.create_or_update(
db, org.address_id, payload.address
)
org.address_id = new_address_id
"""
import uuid
import logging
from typing import Optional, Dict, Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.identity.address import Address, GeoPostalCode
from app.schemas.address import AddressIn, AddressOut
logger = logging.getLogger(__name__)
class AddressManager:
"""Centralized service for all address operations.
Replaces the legacy module-level functions in address_service.py
with a unified, class-based API that returns UUIDs for FK assignment.
"""
# ── Internal Helpers ──────────────────────────────────────────────
@staticmethod
async def _resolve_postal_code(
db: AsyncSession,
zip_code: str,
city: str,
country_code: str = "HU",
) -> Optional[int]:
"""Find or create a GeoPostalCode record and return its ID.
Looks up by zip_code first; if not found, creates a new record.
"""
stmt = select(GeoPostalCode).where(GeoPostalCode.zip_code == zip_code)
result = await db.execute(stmt)
postal = result.scalar_one_or_none()
if not postal:
postal = GeoPostalCode(
zip_code=zip_code,
city=city,
country_code=country_code,
)
db.add(postal)
await db.flush()
logger.info(
"AddressManager: Created GeoPostalCode "
"%s - %s (id=%s)", zip_code, city, postal.id
)
return postal.id
@staticmethod
async def _find_duplicate(
db: AsyncSession,
addr_in: AddressIn,
) -> Optional[Address]:
"""Check if an identical address already exists in system.addresses.
Uses a best-effort match on normalized fields:
- If zip+city+street_name+house_number all match, it's a duplicate.
- Partial matches (zip+city only) are NOT considered duplicates
to avoid false positives.
"""
if not addr_in.zip or not addr_in.city:
return None
# Resolve postal code first to get the FK
postal_code_id = await AddressManager._resolve_postal_code(
db, addr_in.zip, addr_in.city
)
if not postal_code_id:
return None
# Build match criteria
conditions = [Address.postal_code_id == postal_code_id]
if addr_in.street_name:
conditions.append(Address.street_name == addr_in.street_name)
if addr_in.house_number:
conditions.append(Address.house_number == addr_in.house_number)
stmt = select(Address).where(*conditions)
result = await db.execute(stmt)
return result.scalar_one_or_none()
# ── Public API ────────────────────────────────────────────────────
@staticmethod
async def resolve(
db: AsyncSession,
address_data: AddressIn,
) -> Optional[UUID]:
"""Resolve an address: check for duplicates, create if needed.
This is the primary method for 'find-or-create' semantics.
Returns the UUID of the existing or newly created Address record,
or None if address_data is None.
Args:
db: Database session.
address_data: The incoming address data.
Returns:
The Address UUID, or None if address_data was None.
"""
if address_data is None:
return None
# 1. Check for duplicate
existing = await AddressManager._find_duplicate(db, address_data)
if existing is not None:
logger.info(
"AddressManager.resolve: Found duplicate address %s", existing.id
)
return existing.id
# 2. No duplicate — create new
return await AddressManager.create_or_update(db, None, address_data)
@staticmethod
async def create_or_update(
db: AsyncSession,
address_id: Optional[UUID],
data: AddressIn,
) -> Optional[UUID]:
"""Atomic create-or-update for system.addresses records.
- If address_id is provided AND the record exists → UPDATE it.
- If address_id is None or the record doesn't exist → CREATE new.
- If data is None → return the existing address_id unchanged.
Args:
db: Database session.
address_id: The existing Address UUID (or None for create).
data: The incoming address data.
Returns:
The Address UUID, or None if data was None.
"""
if data is None:
return address_id # Keep existing
# ── Try to load existing ──
existing_address: Optional[Address] = None
if address_id is not None:
stmt = select(Address).where(Address.id == address_id)
result = await db.execute(stmt)
existing_address = result.scalar_one_or_none()
if existing_address is not None:
# ── UPDATE existing ──
update_fields = data.model_dump(exclude_unset=True)
# Handle zip/city → postal_code_id resolution
if "zip" in update_fields or "city" in update_fields:
new_zip = update_fields.get("zip") or existing_address.zip
new_city = update_fields.get("city") or existing_address.city
if new_zip and new_city:
postal_code_id = await AddressManager._resolve_postal_code(
db, new_zip, new_city
)
existing_address.postal_code_id = postal_code_id
# Map AddressIn field names to Address model attributes
field_mapping = {
"street_name": "street_name",
"street_type": "street_type",
"house_number": "house_number",
"stairwell": "stairwell",
"floor": "floor",
"door": "door",
"parcel_id": "parcel_id",
"full_address_text": "full_address_text",
"latitude": "latitude",
"longitude": "longitude",
}
for addr_field, model_attr in field_mapping.items():
if model_attr and addr_field in update_fields:
setattr(existing_address, model_attr, update_fields[addr_field])
await db.flush()
logger.info(
"AddressManager: Updated address %s", existing_address.id
)
return existing_address.id
# ── CREATE new ──
new_address = Address(
id=uuid.uuid4(),
street_name=data.street_name,
street_type=data.street_type,
house_number=data.house_number,
stairwell=data.stairwell,
floor=data.floor,
door=data.door,
parcel_id=data.parcel_id,
full_address_text=data.full_address_text,
latitude=data.latitude,
longitude=data.longitude,
)
# Resolve postal code if zip is provided
if data.zip and data.city:
postal_code_id = await AddressManager._resolve_postal_code(
db, data.zip, data.city
)
new_address.postal_code_id = postal_code_id
db.add(new_address)
await db.flush()
logger.info("AddressManager: Created address %s", new_address.id)
return new_address.id
@staticmethod
async def get_normalized(
db: AsyncSession,
address_id: UUID,
) -> Optional[Dict[str, Any]]:
"""Return the full address object including postal code details.
Returns a dict compatible with AddressOut schema, with zip/city
resolved via the postal_code relationship.
Args:
db: Database session.
address_id: The Address UUID to look up.
Returns:
A dict with all address fields, or None if not found.
"""
stmt = select(Address).where(Address.id == address_id)
result = await db.execute(stmt)
address = result.scalar_one_or_none()
if address is None:
return None
return AddressOut.model_validate(address).model_dump()

View File

@@ -0,0 +1,140 @@
"""
Address Service (P0 Refactoring).
Provides helper functions for creating, updating, and resolving
Address records in the system.addresses table.
Usage:
from app.services.address_service import create_or_update_address
"""
import uuid
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.identity.address import Address, GeoPostalCode
from app.schemas.address import AddressIn
logger = logging.getLogger(__name__)
async def _resolve_postal_code(db: AsyncSession, zip_code: str, city: str) -> Optional[int]:
"""Find or create a GeoPostalCode record and return its ID.
Looks up by zip_code first; if not found, creates a new record.
"""
stmt = select(GeoPostalCode).where(GeoPostalCode.zip_code == zip_code)
result = await db.execute(stmt)
postal = result.scalar_one_or_none()
if not postal:
postal = GeoPostalCode(
zip_code=zip_code,
city=city,
country_code="HU",
)
db.add(postal)
await db.flush()
logger.info(f"Created new GeoPostalCode: {zip_code} - {city} (id={postal.id})")
return postal.id
async def create_address(db: AsyncSession, addr_in: AddressIn) -> Address:
"""Create a new Address record from AddressIn data.
If zip and city are provided, resolves/creates a GeoPostalCode record
and links it via postal_code_id.
"""
address = Address(
id=uuid.uuid4(),
street_name=addr_in.street_name,
street_type=addr_in.street_type,
house_number=addr_in.house_number,
stairwell=addr_in.stairwell,
floor=addr_in.floor,
door=addr_in.door,
parcel_id=addr_in.parcel_id,
full_address_text=addr_in.full_address_text,
latitude=addr_in.latitude,
longitude=addr_in.longitude,
)
# Resolve postal code if zip is provided
if addr_in.zip and addr_in.city:
postal_code_id = await _resolve_postal_code(db, addr_in.zip, addr_in.city)
address.postal_code_id = postal_code_id
db.add(address)
await db.flush()
logger.info(f"Created Address {address.id}")
return address
async def update_address(db: AsyncSession, address: Address, addr_in: AddressIn) -> Address:
"""Update an existing Address record from AddressIn data.
Only updates fields that are explicitly set (not None) in addr_in.
If zip/city changes, resolves the new GeoPostalCode.
"""
update_fields = addr_in.model_dump(exclude_unset=True)
# Handle zip/city -> postal_code_id resolution
if "zip" in update_fields or "city" in update_fields:
new_zip = update_fields.get("zip") or address.zip
new_city = update_fields.get("city") or address.city
if new_zip and new_city:
postal_code_id = await _resolve_postal_code(db, new_zip, new_city)
address.postal_code_id = postal_code_id
# Map AddressIn field names to Address model attributes
field_mapping = {
"zip": None, # handled above
"city": None, # handled above
"street_name": "street_name",
"street_type": "street_type",
"house_number": "house_number",
"stairwell": "stairwell",
"floor": "floor",
"door": "door",
"parcel_id": "parcel_id",
"full_address_text": "full_address_text",
"latitude": "latitude",
"longitude": "longitude",
}
for addr_field, model_attr in field_mapping.items():
if model_attr and addr_field in update_fields:
setattr(address, model_attr, update_fields[addr_field])
await db.flush()
logger.info(f"Updated Address {address.id}")
return address
async def create_or_update_address(
db: AsyncSession,
addr_in: Optional[AddressIn],
existing_address: Optional[Address] = None,
) -> Optional[Address]:
"""Create or update an Address record based on whether one already exists.
This is the main helper used by PATCH endpoints.
Args:
db: Database session.
addr_in: The incoming address data (or None to skip).
existing_address: The existing Address record (or None to create new).
Returns:
The Address record, or None if addr_in was None.
"""
if addr_in is None:
return existing_address # Keep existing if no new data
if existing_address:
return await update_address(db, existing_address, addr_in)
else:
return await create_address(db, addr_in)

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(