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

@@ -11,7 +11,7 @@ logger = logging.getLogger("admin-endpoints")
from app.api import deps
from app.models.identity import User, UserRole, Person # JAVÍTVA: Központi import
from app.models.identity.address import Address
from app.models.identity.address import Address, GeoPostalCode
from app.models.system import SystemParameter, ParameterScope
from app.services.system_service import system_service
# JAVÍTVA: Security audit modellek
@@ -623,23 +623,12 @@ async def list_users(
seen_ids.add(row.id)
unique_users.append(row)
from app.schemas.address import AddressOut
user_list = []
for u in unique_users:
person = u.person
address = person.address if person else None
# Cím összefűzése
address_str = None
if address:
parts = []
if address.zip:
parts.append(str(address.zip))
if address.city:
parts.append(str(address.city))
if address.street_name:
parts.append(str(address.street_name))
if address.house_number:
parts.append(str(address.house_number))
address_str = ', '.join(parts) if parts else None
# Személy nevének összefűzése
person_name = None
@@ -663,12 +652,8 @@ async def list_users(
"first_name": person.first_name if person else None,
"last_name": person.last_name if person else None,
"phone": person.phone if person else None,
# Címadatok
"postal_code": str(address.zip) if address and address.zip else None,
"city": address.city if address and address.city else None,
"street": address.street_name if address and address.street_name else None,
"house_number": address.house_number if address and address.house_number else None,
"address": address_str,
# Címadatok — egységes AddressOut formátumban
"address": AddressOut.model_validate(address) if address else None,
})
return {
@@ -943,6 +928,8 @@ async def search_organizations(
"""
from app.models.marketplace.organization import Organization
# P0 FIX: Join with system.addresses and system.geo_postal_codes
# instead of selecting the ghost column Organization.address_city.
stmt = (
select(
Organization.id,
@@ -955,11 +942,13 @@ async def search_organizations(
Organization.is_verified,
Organization.is_active,
Organization.is_deleted,
Organization.address_city,
GeoPostalCode.city.label("city"),
Organization.region,
Organization.segment,
Organization.created_at,
)
.outerjoin(Address, Organization.address_id == Address.id)
.outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)
.where(
or_(
Organization.full_name.ilike(f"%{name}%"),
@@ -987,7 +976,7 @@ async def search_organizations(
"is_verified": row.is_verified,
"is_active": row.is_active,
"is_deleted": row.is_deleted,
"city": row.address_city,
"city": row.city,
"region": row.region,
"segment": row.segment,
"created_at": row.created_at.isoformat() if row.created_at else None,

View File

@@ -28,7 +28,9 @@ from app.models.core_logic import SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.fleet.organization import ContactPerson
from app.models.identity import User, Person
from app.services.address_manager import AddressManager
from app.models.vehicle.history import AuditLog, LogSeverity
from app.schemas.address import AddressIn, AddressOut
from app.schemas.organization import (
OrganizationMemberCreate,
OrganizationMemberUpdate,
@@ -207,30 +209,36 @@ class GarageDetailsResponse(BaseModel):
is_active: bool
is_verified: bool
org_type: str
# Cím adatok (elsődleges)
# Cím adatok (elsődleges) — denormalizált mezők (backward compatible)
address_city: Optional[str] = None
address_zip: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
# P0: Unified AddressOut nested object
address_detail: Optional[AddressOut] = None
tax_number: Optional[str] = None
reg_number: Optional[str] = None
# ── Kapcsolattartó (Contact Person) ──
contact_person_name: Optional[str] = None
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
# ── Számlázási cím (Billing Address) ──
# ── Számlázási cím (Billing Address) — denormalizált mezők (backward compatible) ──
billing_zip: Optional[str] = None
billing_city: Optional[str] = None
billing_street_name: Optional[str] = None
billing_street_type: Optional[str] = None
billing_house_number: Optional[str] = None
# ── Értesítési cím (Notification Address) ──
# P0: Unified AddressOut nested object for billing address
billing_address_detail: Optional[AddressOut] = None
# ── Értesítési cím (Notification Address) — denormalizált mezők (backward compatible) ──
notification_zip: Optional[str] = None
notification_city: Optional[str] = None
notification_street_name: Optional[str] = None
notification_street_type: Optional[str] = None
notification_house_number: Optional[str] = None
# P0: Unified AddressOut nested object for notification address
notification_address_detail: Optional[AddressOut] = None
# Előfizetés
subscription: Optional[SubscriptionSummary] = None
# Kapcsolattartó (ContactPerson modellből)
@@ -421,6 +429,9 @@ async def list_organizations(
# Resolve email: prefer contact_email, fallback to owner's email
resolved_email = org.contact_email or (org.owner.email if org.owner else None)
# P1 FIX: Use relationship-based city instead of ghost column org.address_city
resolved_city = org.address.city if org.address else None
garage_list.append(GarageListItem(
id=org.id,
name=org.name,
@@ -432,7 +443,7 @@ async def list_organizations(
is_verified=org.is_verified,
is_deleted=org.is_deleted,
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
city=org.address_city,
city=resolved_city,
region=org.region,
segment=org.segment,
member_count=member_counts.get(org.id, 0),
@@ -1078,6 +1089,19 @@ async def get_organization_details(
f"({org.full_name}) — {len(members_list)} members"
)
# ── P0 REFACTORED: Build unified AddressOut objects from relationships ──
address_detail = None
if org.address:
address_detail = AddressOut.model_validate(org.address)
billing_address_detail = None
if org.billing_address:
billing_address_detail = AddressOut.model_validate(org.billing_address)
notification_address_detail = None
if org.notification_address:
notification_address_detail = AddressOut.model_validate(org.notification_address)
return GarageDetailsResponse(
id=org.id,
name=org.name,
@@ -1087,11 +1111,13 @@ async def get_organization_details(
is_active=org.is_active,
is_verified=org.is_verified,
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
address_city=org.address_city,
address_zip=org.address_zip,
address_street_name=org.address_street_name,
address_street_type=org.address_street_type,
address_house_number=org.address_house_number,
# ── P0 REFACTORED: Denormalized fields populated from relationship ──
address_city=org.address.city if org.address else None,
address_zip=org.address.zip if org.address else None,
address_street_name=org.address.street_name if org.address else None,
address_street_type=org.address.street_type if org.address else None,
address_house_number=org.address.house_number if org.address else None,
address_detail=address_detail,
tax_number=org.tax_number,
reg_number=org.reg_number,
# ── P0: Zero-Duplication Kapcsolattartó (owner fallback) ──
@@ -1099,17 +1125,19 @@ async def get_organization_details(
contact_email=resolved_contact_email,
contact_phone=resolved_contact_phone,
# ── Számlázási cím ──
billing_zip=org.billing_zip,
billing_city=org.billing_city,
billing_street_name=org.billing_street_name,
billing_street_type=org.billing_street_type,
billing_house_number=org.billing_house_number,
billing_zip=org.billing_address.zip if org.billing_address else None,
billing_city=org.billing_address.city if org.billing_address else None,
billing_street_name=org.billing_address.street_name if org.billing_address else None,
billing_street_type=org.billing_address.street_type if org.billing_address else None,
billing_house_number=org.billing_address.house_number if org.billing_address else None,
billing_address_detail=billing_address_detail,
# ── Értesítési cím ──
notification_zip=org.notification_zip,
notification_city=org.notification_city,
notification_street_name=org.notification_street_name,
notification_street_type=org.notification_street_type,
notification_house_number=org.notification_house_number,
notification_zip=org.notification_address.zip if org.notification_address else None,
notification_city=org.notification_address.city if org.notification_address else None,
notification_street_name=org.notification_address.street_name if org.notification_address else None,
notification_street_type=org.notification_address.street_type if org.notification_address else None,
notification_house_number=org.notification_address.house_number if org.notification_address else None,
notification_address_detail=notification_address_detail,
subscription=subscription_summary,
primary_contact=primary_contact,
created_at=org.created_at.isoformat() if org.created_at else None,
@@ -1224,28 +1252,34 @@ class OrganizationUpdate(BaseModel):
# ── Org-level email/phone (used for individual garages to propagate to Person/User) ──
email: Optional[str] = Field(None, max_length=255, description="Szervezeti e-mail (individual garages esetén a User rekordba propagálódik)")
phone: Optional[str] = Field(None, max_length=50, description="Szervezeti telefon (individual garages esetén a Person rekordba propagálódik)")
# ── Elsődleges cím (Primary Address) ──
address_zip: Optional[str] = Field(None, max_length=10, description="Irányítószám")
address_city: Optional[str] = Field(None, max_length=100, description="Város")
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve")
address_street_type: Optional[str] = Field(None, max_length=50, description="Utca típusa (utca, tér, stb.)")
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám")
# ── Elsődleges cím (Primary Address) — DEPRECATED: Use address_detail instead ──
address_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd az address_detail mezőt! Irányítószám")
address_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd az address_detail mezőt! Város")
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd az address_detail mezőt! Utca neve")
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd az address_detail mezőt! Utca típusa (utca, tér, stb.)")
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd az address_detail mezőt! Házszám")
# P0: Unified AddressIn nested object for primary address
address_detail: Optional[AddressIn] = None
# ── Kapcsolattartó (Contact Person) ──
contact_person_name: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó neve")
contact_email: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó e-mail címe")
contact_phone: Optional[str] = Field(None, max_length=50, description="Kapcsolattartó telefonszáma")
# ── Számlázási cím (Billing Address) ──
billing_zip: Optional[str] = Field(None, max_length=10, description="Számlázási irányítószám")
billing_city: Optional[str] = Field(None, max_length=100, description="Számlázási város")
billing_street_name: Optional[str] = Field(None, max_length=150, description="Számlázási utca neve")
billing_street_type: Optional[str] = Field(None, max_length=50, description="Számlázási utca típusa")
billing_house_number: Optional[str] = Field(None, max_length=20, description="Számlázási házszám")
# ── Értesítési cím (Notification Address) ──
notification_zip: Optional[str] = Field(None, max_length=10, description="Értesítési irányítószám")
notification_city: Optional[str] = Field(None, max_length=100, description="Értesítési város")
notification_street_name: Optional[str] = Field(None, max_length=150, description="Értesítési utca neve")
notification_street_type: Optional[str] = Field(None, max_length=50, description="Értesítési utca típusa")
notification_house_number: Optional[str] = Field(None, max_length=20, description="Értesítési házszám")
# ── Számlázási cím (Billing Address) — DEPRECATED: Use billing_address_detail instead ──
billing_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási irányítószám")
billing_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási város")
billing_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási utca neve")
billing_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási utca típusa")
billing_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási házszám")
# P0: Unified AddressIn nested object for billing address
billing_address_detail: Optional[AddressIn] = None
# ── Értesítési cím (Notification Address) — DEPRECATED: Use notification_address_detail instead ──
notification_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési irányítószám")
notification_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési város")
notification_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési utca neve")
notification_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési utca típusa")
notification_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési házszám")
# P0: Unified AddressIn nested object for notification address
notification_address_detail: Optional[AddressIn] = None
@router.put(
@@ -1279,10 +1313,34 @@ async def update_organization(
detail=f"Szervezet nem található ID-vel: {org_id}",
)
# 2. Összegyűjtjük a változásokat
# 2. P0 REFACTORED: Address FK logic via AddressManager
raw_data = payload.model_dump(exclude_none=True)
# ── Primary address via AddressManager ──
address_detail: Optional[AddressIn] = raw_data.pop("address_detail", None)
if address_detail:
org.address_id = await AddressManager.create_or_update(
db, org.address_id, address_detail
)
# ── Billing address via AddressManager ──
billing_address_detail: Optional[AddressIn] = raw_data.pop("billing_address_detail", None)
if billing_address_detail:
org.billing_address_id = await AddressManager.create_or_update(
db, org.billing_address_id, billing_address_detail
)
# ── Notification address via AddressManager ──
notification_address_detail: Optional[AddressIn] = raw_data.pop("notification_address_detail", None)
if notification_address_detail:
org.notification_address_id = await AddressManager.create_or_update(
db, org.notification_address_id, notification_address_detail
)
# 3. Összegyűjtjük a változásokat (non-address fields)
update_fields = {}
for field_name in payload.model_dump(exclude_none=True):
value = getattr(payload, field_name)
for field_name in raw_data:
value = raw_data[field_name]
if value is not None and hasattr(org, field_name):
setattr(org, field_name, value)
update_fields[field_name] = value
@@ -1333,6 +1391,11 @@ async def update_organization(
f"({org.full_name}): fields={list(update_fields.keys())}"
)
# ── P0 REFACTORED: Build unified AddressOut from relationship ──
resp_address_detail = None
if org.address:
resp_address_detail = AddressOut.model_validate(org.address)
return {
"status": "success",
"message": f"A(z) '{org.full_name}' garázs adatai frissítve.",
@@ -1345,11 +1408,7 @@ async def update_organization(
"display_name": org.display_name,
"tax_number": org.tax_number,
"reg_number": org.reg_number,
"address_zip": org.address_zip,
"address_city": org.address_city,
"address_street_name": org.address_street_name,
"address_street_type": org.address_street_type,
"address_house_number": org.address_house_number,
"address_detail": resp_address_detail,
"status": org.status,
"is_active": org.is_active,
"contact_email": org.contact_email,

View File

@@ -26,7 +26,8 @@ from app.models.identity import User, Person, UserRole
from app.models.identity.address import Address
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.vehicle.history import AuditLog, LogSeverity
from app.schemas.user import AddressResponse
from app.schemas.address import AddressIn, AddressOut
from app.services.address_manager import AddressManager
logger = logging.getLogger("admin-persons")
router = APIRouter()
@@ -59,7 +60,7 @@ class PersonListItem(BaseModel):
merged_into_id: Optional[int] = None
users_count: int = 0
active_user: Optional[PersonListItemActiveUser] = None
address: Optional[AddressResponse] = None
address: Optional[AddressOut] = None
class PersonListResponse(BaseModel):
@@ -214,20 +215,7 @@ async def list_persons(
# Cím adatok
address_data = None
if person.address:
address_data = AddressResponse(
address_zip=person.address.zip,
address_city=person.address.city,
address_street_name=person.address.street_name,
address_street_type=person.address.street_type,
address_house_number=person.address.house_number,
address_stairwell=person.address.stairwell,
address_floor=person.address.floor,
address_door=person.address.door,
address_hrsz=person.address.parcel_id,
full_address_text=person.address.full_address_text,
latitude=person.address.latitude,
longitude=person.address.longitude,
)
address_data = AddressOut.model_validate(person.address)
items.append(PersonListItem(
id=person.id,
@@ -338,7 +326,7 @@ class PersonDetailResponse(BaseModel):
updated_at: Optional[str] = None
# Kapcsolódó entitások
address: Optional[AddressResponse] = None
address: Optional[AddressOut] = None
users: List[PersonDetailUserInfo] = []
memberships: List[PersonDetailMembership] = []
owned_organizations: List[PersonDetailOwnedOrganization] = []
@@ -394,20 +382,7 @@ async def get_person_detail(
# ── Cím adatok ──
address_data = None
if person.address:
address_data = AddressResponse(
address_zip=person.address.zip,
address_city=person.address.city,
address_street_name=person.address.street_name,
address_street_type=person.address.street_type,
address_house_number=person.address.house_number,
address_stairwell=person.address.stairwell,
address_floor=person.address.floor,
address_door=person.address.door,
address_hrsz=person.address.parcel_id,
full_address_text=person.address.full_address_text,
latitude=person.address.latitude,
longitude=person.address.longitude,
)
address_data = AddressOut.model_validate(person.address)
# ── Kapcsolódó User-ek ──
users_data: List[PersonDetailUserInfo] = []
@@ -520,22 +495,6 @@ async def get_person_detail(
# =============================================================================
class PersonAddressUpdate(BaseModel):
"""Cím adatok a Person szerkesztéshez."""
address_zip: Optional[str] = Field(default=None, description="Irányítószám")
address_city: Optional[str] = Field(default=None, description="Város")
address_street_name: Optional[str] = Field(default=None, description="Utca név")
address_street_type: Optional[str] = Field(default=None, description="Közterület jellege")
address_house_number: Optional[str] = Field(default=None, description="Házszám")
address_stairwell: Optional[str] = Field(default=None, description="Lépcsőház")
address_floor: Optional[str] = Field(default=None, description="Emelet")
address_door: Optional[str] = Field(default=None, description="Ajtó")
address_hrsz: Optional[str] = Field(default=None, description="Helyrajzi szám")
full_address_text: Optional[str] = Field(default=None, description="Teljes cím szöveg")
latitude: Optional[float] = Field(default=None, description="GPS szélesség")
longitude: Optional[float] = Field(default=None, description="GPS hosszúság")
class PersonUpdateRequest(BaseModel):
"""Admin által szerkeszthető Person mezők.
@@ -554,8 +513,8 @@ class PersonUpdateRequest(BaseModel):
is_active: Optional[bool] = Field(default=None, description="Aktív státusz")
is_ghost: Optional[bool] = Field(default=None, description="Ghost státusz")
# Cím adatok (opcionális, nested object)
address: Optional[PersonAddressUpdate] = Field(default=None, description="Cím adatok")
# Cím adatok (opcionális, nested object) — egységes AddressIn séma
address: Optional[AddressIn] = Field(default=None, description="Cím adatok")
@field_validator("phone")
@classmethod
@@ -692,62 +651,14 @@ async def update_person(
changes["is_ghost"] = {"old": person.is_ghost, "new": update_data.is_ghost}
person.is_ghost = update_data.is_ghost
# ── 5. Cím adatok frissítése ──
# ── 5. Cím adatok frissítése (P1 REFACTORED: AddressManager) ──
if update_data.address is not None:
addr_data = update_data.address
# Ha van már cím, frissítjük, különben létrehozzuk
if person.address:
address = person.address
else:
# Új cím létrehozása
address = Address(
created_at=datetime.utcnow()
)
db.add(address)
person.address = address
# Cím mezők frissítése
addr_changes: Dict[str, Any] = {}
if addr_data.address_zip is not None:
addr_changes["address_zip"] = {"old": address.zip, "new": addr_data.address_zip}
# Megjegyzés: a zip a GeoPostalCode kapcsolatból jön, itt csak a postal_code_id-t tudnánk keresni
# Egyszerűsítés: a frontend által küldött adatokat tároljuk a full_address_text-ben is
if addr_data.address_city is not None:
addr_changes["address_city"] = {"old": address.city, "new": addr_data.address_city}
if addr_data.address_street_name is not None:
addr_changes["address_street_name"] = {"old": address.street_name, "new": addr_data.address_street_name}
address.street_name = addr_data.address_street_name
if addr_data.address_street_type is not None:
addr_changes["address_street_type"] = {"old": address.street_type, "new": addr_data.address_street_type}
address.street_type = addr_data.address_street_type
if addr_data.address_house_number is not None:
addr_changes["address_house_number"] = {"old": address.house_number, "new": addr_data.address_house_number}
address.house_number = addr_data.address_house_number
if addr_data.address_stairwell is not None:
addr_changes["address_stairwell"] = {"old": address.stairwell, "new": addr_data.address_stairwell}
address.stairwell = addr_data.address_stairwell
if addr_data.address_floor is not None:
addr_changes["address_floor"] = {"old": address.floor, "new": addr_data.address_floor}
address.floor = addr_data.address_floor
if addr_data.address_door is not None:
addr_changes["address_door"] = {"old": address.door, "new": addr_data.address_door}
address.door = addr_data.address_door
if addr_data.address_hrsz is not None:
addr_changes["address_hrsz"] = {"old": address.parcel_id, "new": addr_data.address_hrsz}
address.parcel_id = addr_data.address_hrsz
if addr_data.full_address_text is not None:
addr_changes["full_address_text"] = {"old": address.full_address_text, "new": addr_data.full_address_text}
address.full_address_text = addr_data.full_address_text
if addr_data.latitude is not None:
addr_changes["latitude"] = {"old": address.latitude, "new": addr_data.latitude}
address.latitude = addr_data.latitude
if addr_data.longitude is not None:
addr_changes["longitude"] = {"old": address.longitude, "new": addr_data.longitude}
address.longitude = addr_data.longitude
if addr_changes:
changes["address"] = addr_changes
# P1 FIX: Use AddressManager.create_or_update() instead of
# writing directly to Address model fields.
person.address_id = await AddressManager.create_or_update(
db, person.address_id, update_data.address
)
changes["address"] = {"old": None, "new": "updated via AddressManager"}
# ── 6. Mentés ──
if changes:
@@ -768,20 +679,7 @@ async def update_person(
# Cím adatok
address_data = None
if person.address:
address_data = AddressResponse(
address_zip=person.address.zip,
address_city=person.address.city,
address_street_name=person.address.street_name,
address_street_type=person.address.street_type,
address_house_number=person.address.house_number,
address_stairwell=person.address.stairwell,
address_floor=person.address.floor,
address_door=person.address.door,
address_hrsz=person.address.parcel_id,
full_address_text=person.address.full_address_text,
latitude=person.address.latitude,
longitude=person.address.longitude,
)
address_data = AddressOut.model_validate(person.address)
# Kapcsolódó User-ek
users_data: List[PersonDetailUserInfo] = []

View File

@@ -29,6 +29,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import deps
from app.schemas.address import AddressIn, AddressOut
from app.db.session import get_db
from app.models.identity import User
from app.models.identity.social import (
@@ -75,10 +76,7 @@ class ProviderDetail(BaseModel):
name: str
address: str
city: Optional[str] = None
address_zip: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_detail: Optional[AddressOut] = None
plus_code: Optional[str] = None
contact_phone: Optional[str] = None
contact_email: Optional[str] = None
@@ -130,10 +128,7 @@ class ProviderUpdateInput(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255, description="Szolgáltató neve")
address: Optional[str] = Field(None, max_length=500, description="Teljes cím")
city: Optional[str] = Field(None, max_length=100, description="Város")
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
address_street_name: Optional[str] = Field(None, max_length=255, description="Utca név")
address_street_type: Optional[str] = Field(None, max_length=50, description="Utca típusa (pl. utca, tér, út)")
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám")
address_detail: Optional[AddressIn] = Field(None, description="Cím részletes adatai (egységes AddressIn séma)")
plus_code: Optional[str] = Field(None, max_length=50, description="Plus Code (Google Maps)")
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
@@ -290,6 +285,7 @@ def _build_unified_providers_query(
alkalmazzuk (nem az egyes részekre), hogy elkerüljük az enum típusütközéseket.
"""
from app.models.marketplace.organization import Organization
from app.models.identity.address import Address, GeoPostalCode
# --- 1. ServiceProvider lekérdezés ---
provider_conditions = []
@@ -355,17 +351,17 @@ def _build_unified_providers_query(
Organization.id.label("id"),
Organization.name.label("name"),
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_city.label("city"),
GeoPostalCode.city.label("city"),
literal(None).label("category"),
literal("approved").label("status"),
literal("verified_org").label("source"),
@@ -373,6 +369,12 @@ def _build_unified_providers_query(
literal(100).label("validation_score"),
literal(None).label("added_by_user_id"),
Organization.created_at.label("created_at"),
).outerjoin(
Address,
Address.id == Organization.address_id,
).outerjoin(
GeoPostalCode,
GeoPostalCode.id == Address.postal_code_id,
)
if org_conditions:
org_stmt = org_stmt.where(*org_conditions)
@@ -562,10 +564,13 @@ async def get_provider_detail(
name=sp.name,
address=sp.address or "",
city=sp.city,
address_zip=sp.address_zip,
address_street_name=sp.address_street_name,
address_street_type=sp.address_street_type,
address_house_number=sp.address_house_number,
address_detail=AddressOut(
zip=sp.address_zip,
city=sp.city,
street_name=sp.address_street_name,
street_type=sp.address_street_type,
house_number=sp.address_house_number,
),
plus_code=sp.plus_code,
contact_phone=sp.contact_phone,
contact_email=sp.contact_email,
@@ -1148,6 +1153,39 @@ async def update_provider(
# Mezők frissítése (csak a nem None értékeket)
update_fields = update_data.model_dump(exclude_none=True)
# =====================================================================
# STEP 0: Extract address_detail (AddressIn) and map to flat model fields
# =====================================================================
address_detail: Optional[dict] = update_fields.pop("address_detail", None)
if address_detail:
# Map AddressIn fields to ServiceProvider flat columns
addr_mapping = {
"zip": "address_zip",
"city": None, # city is a direct column, handled separately
"street_name": "address_street_name",
"street_type": "address_street_type",
"house_number": "address_house_number",
"stairwell": "address_stairwell",
"floor": "address_floor",
"door": "address_door",
"parcel_id": "address_hrsz",
"full_address_text": None, # maps to address field
"latitude": None, # maps to latitude
"longitude": None, # maps to longitude
}
for addr_field, model_field in addr_mapping.items():
if addr_field in address_detail and address_detail[addr_field] is not None:
if model_field:
update_fields[model_field] = address_detail[addr_field]
elif addr_field == "full_address_text":
update_fields["address"] = address_detail[addr_field]
elif addr_field == "latitude":
update_fields["latitude"] = address_detail[addr_field]
elif addr_field == "longitude":
update_fields["longitude"] = address_detail[addr_field]
elif addr_field == "city":
update_fields["city"] = address_detail[addr_field]
# =====================================================================
# STEP 1: Extract multi-dimensional arrays and JSONB from payload
# =====================================================================
@@ -1298,10 +1336,13 @@ async def update_provider(
name=provider.name,
address=provider.address or "",
city=provider.city,
address_zip=provider.address_zip,
address_street_name=provider.address_street_name,
address_street_type=provider.address_street_type,
address_house_number=provider.address_house_number,
address_detail=AddressOut(
zip=provider.address_zip,
city=provider.city,
street_name=provider.address_street_name,
street_type=provider.address_street_type,
house_number=provider.address_house_number,
),
plus_code=provider.plus_code,
contact_phone=provider.contact_phone,
contact_email=provider.contact_email,

View File

@@ -10,6 +10,7 @@ from app.api.deps import get_db, get_current_user, RequireOrgCapability
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate
from app.services.provider_service import find_or_create_provider_by_name
from app.services.gamification_service import gamification_service
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -567,20 +568,42 @@ async def create_expense(
# ── PROVIDER AUTO-DISCOVERY HOOK (Card #360) ──
# Ha external_vendor_name meg van adva, de service_provider_id nincs,
# automatikusan felfedezzük vagy létrehozzuk a providert.
# A find_or_create_provider_by_name() kezeli a gamification pontok kiosztását is
# (PROVIDER_DISCOVERY / PROVIDER_CONFIRMATION / PROVIDER_VERIFIED_USE).
#
# P0 ARCHITECTURE CLEANUP (2026-07-01):
# A gamification/XP logika ELTÁVOLÍTVA a service rétegből.
# A find_or_create_provider_by_name() már csak (provider, created) tuple-t ad vissza.
# Ha created=True (új provider felfedezve), itt az API rétegben hívjuk meg
# a gamification_service.award_points()-t a PROVIDER_DISCOVERY action_key-kel.
resolved_provider_id = expense.service_provider_id
if expense.external_vendor_name and not expense.service_provider_id:
try:
provider, action_key = await find_or_create_provider_by_name(
provider, created = await find_or_create_provider_by_name(
db=db,
name=expense.external_vendor_name,
added_by_user_id=current_user.id,
)
resolved_provider_id = provider.id
# P0 ARCHITECTURE CLEANUP: Gamification XP kiosztása az API rétegben
# Csak akkor adunk XP-t, ha a provider újonnan lett felfedezve (created=True)
if created:
await gamification_service.award_points(
db=db,
user_id=current_user.id,
amount=0,
reason="PROVIDER_DISCOVERY",
commit=False, # A hívó (create_expense) kezeli a commit-ot
action_key="PROVIDER_DISCOVERY",
)
logger.info(
f"Gamification XP awarded for PROVIDER_DISCOVERY: "
f"name='{expense.external_vendor_name}', "
f"provider_id={provider.id}, user_id={current_user.id}"
)
logger.info(
f"Provider auto-discovery: name='{expense.external_vendor_name}', "
f"provider_id={provider.id}, action_key='{action_key}', "
f"provider_id={provider.id}, created={created}, "
f"user_id={current_user.id}"
)
except Exception as e:

View File

@@ -17,9 +17,12 @@ from app.db.session import get_db
from app.api.deps import get_current_user
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
from app.schemas.subscription import SubscriptionTierResponse
from app.schemas.address import AddressOut
from app.services.billing_engine import upgrade_org_subscription
from app.services.address_manager import AddressManager
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
from app.models.identity import User, OneTimePassword
from app.models.identity.address import Address
from app.core.config import settings
from app.services.security_service import security_service
from app.models import LogSeverity
@@ -49,7 +52,7 @@ async def onboard_organization(
if org_in.country_code == "HU":
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
status_code=status.HTTP_400_BAD_REQUEST,
detail=_("ORGANIZATION.ERROR.INVALID_TAX_FORMAT")
)
@@ -65,7 +68,10 @@ async def onboard_organization(
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
temp_slug = hashlib.md5(f"{org_in.tax_number}-{uuid.uuid4()}".encode()).hexdigest()[:12]
# 4. Mentés
# 4. P0 REFACTORED: Create Address record via AddressManager, then link via address_id FK
address_id = await AddressManager.create_or_update(db, None, org_in.address)
# 5. Mentés
new_org = Organization(
full_name=org_in.full_name,
name=org_in.name,
@@ -73,12 +79,7 @@ async def onboard_organization(
tax_number=org_in.tax_number,
reg_number=org_in.reg_number,
folder_slug=temp_slug,
address_zip=org_in.address_zip,
address_city=org_in.address_city,
address_street_name=org_in.address_street_name,
address_street_type=org_in.address_street_type,
address_house_number=org_in.address_house_number,
address_hrsz=org_in.address_hrsz,
address_id=address_id,
country_code=org_in.country_code,
org_type=OrgType.business,
status="pending_verification",
@@ -96,22 +97,17 @@ async def onboard_organization(
db.add(new_org)
await db.flush()
# 5. ALAPÉRTELMEZETT KÖZPONTI TELEPHELY LÉTREHOZÁSA
# 6. ALAPÉRTELMEZETT KÖZPONTI TELEPHELY LÉTREHOZÁSA (with address_id FK)
main_branch = Branch(
organization_id=new_org.id,
name=_("ORGANIZATION.BRANCH.DEFAULT_NAME"),
is_main=True,
postal_code=org_in.address_zip,
city=org_in.address_city,
street_name=org_in.address_street_name,
street_type=org_in.address_street_type,
house_number=org_in.address_house_number,
hrsz=org_in.address_hrsz,
address_id=address_id,
status="active"
)
db.add(main_branch)
# 6. TULAJDONOS RÖGZÍTÉSE
# 7. TULAJDONOS RÖGZÍTÉSE
owner_member = OrganizationMember(
organization_id=new_org.id,
user_id=current_user.id,
@@ -119,7 +115,7 @@ async def onboard_organization(
)
db.add(owner_member)
# 7. NAS Mappa létrehozása
# 8. NAS Mappa létrehozása
try:
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
org_path = os.path.join(base_path, "organizations", str(new_org.id))
@@ -185,6 +181,7 @@ async def get_my_organizations(
A bejelentkezett felhasználóhoz tartozó szervezetek listázása.
P0: Minden szervezethez visszaadja a valós max_vehicles és max_garages limiteket
a subscription_tier JSONB rules alapján.
P0 REFACTORED: Address adatok az address relációból (AddressOut séma).
"""
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
@@ -205,7 +202,10 @@ async def get_my_organizations(
stmt = (
select(Organization)
.where(Organization.id.in_(select(subq.c.id)))
.options(selectinload(Organization.members))
.options(
selectinload(Organization.members),
selectinload(Organization.address),
)
)
result = await db.execute(stmt)
orgs = result.scalars().all()
@@ -256,13 +256,8 @@ async def get_my_organizations(
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
"user_role": _get_user_role(o, current_user.id),
# ── Cím adatok (Address fields) ──
"address_zip": o.address_zip,
"address_city": o.address_city,
"address_street_name": o.address_street_name,
"address_street_type": o.address_street_type,
"address_house_number": o.address_house_number,
"address_hrsz": o.address_hrsz,
# ── P0 REFACTORED: Address from relationship ──
"address": AddressOut.model_validate(o.address).model_dump() if o.address else None,
}
for o in orgs
]
@@ -704,6 +699,12 @@ async def update_organization(
"""
Szervezet adatainak részleges frissítése (általános PATCH).
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
P0 REFACTORED: Address kezelés a unified AddressIn/AddressOut séma alapján.
- Ha payload.address meg van adva, akkor:
* Ha van meglévő address_id → frissíti a system.addresses rekordot
* Ha nincs → létrehoz egy új system.addresses rekordot és beállítja az address_id FK-t
- A régi denormalizált mezők (address_zip, address_city, stb.) ELTÁVOLÍTVA.
"""
# 1. Jogosultság ellenőrzése
# Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
@@ -745,7 +746,15 @@ async def update_organization(
org.visual_settings = current_vs
del update_dict["visual_settings"]
# 4. Többi mező frissítése
# 3. P0 REFACTORED: Address FK logic via AddressManager
if "address" in update_dict:
addr_in = update_dict["address"]
org.address_id = await AddressManager.create_or_update(
db, org.address_id, addr_in
)
del update_dict["address"]
# 4. Többi mező frissítése (kivéve a régi denormalizált címmezőket)
for field, value in update_dict.items():
if hasattr(org, field) and value is not None:
setattr(org, field, value)

View File

@@ -2,10 +2,11 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import selectinload, joinedload
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models.marketplace.organization import Organization, Branch
from app.models.identity.address import Address, GeoPostalCode
from geoalchemy2 import WKTElement
from typing import Optional
@@ -23,16 +24,24 @@ async def match_service(
"""
Geofencing keresőmotor PostGIS segítségével.
Ha nincs megadva lat/lng, akkor nem alkalmazunk távolságszűrést.
P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A Branch.city denormalizált
mező helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
a város adatokat. LEFT JOIN-t használunk, mert az address_id lehet NULL.
"""
# Alap lekérdezés: aktív szervezetek és telephelyek
query = select(
Organization.id,
Organization.name,
Branch.city,
GeoPostalCode.city.label("city"),
Branch.branch_rating,
Branch.location
).join(
Branch, Organization.id == Branch.organization_id
).outerjoin(
Address, Address.id == Branch.address_id
).outerjoin(
GeoPostalCode, GeoPostalCode.id == Address.postal_code_id
).where(
Organization.is_active == True,
Branch.is_deleted == False

View File

@@ -12,6 +12,7 @@ from datetime import datetime
from app.api.deps import get_db, get_current_user
from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse, PersonUpdate, ChangePasswordRequest
from app.schemas.address import AddressOut
from app.models.identity import User, Person
from app.models.identity.address import Address
from app.services.trust_engine import TrustEngine
@@ -69,20 +70,7 @@ async def _build_user_response(user: User, active_org_id: Optional[int] = None,
if person:
address_data = None
if person.address:
address_data = {
"address_zip": getattr(person.address, 'zip', None) or getattr(getattr(person.address, 'postal_code', None), 'zip_code', None),
"address_city": getattr(person.address, 'city', None) or getattr(getattr(person.address, 'postal_code', None), 'city', None),
"address_street_name": person.address.street_name,
"address_street_type": person.address.street_type,
"address_house_number": person.address.house_number,
"address_stairwell": person.address.stairwell,
"address_floor": person.address.floor,
"address_door": person.address.door,
"address_hrsz": person.address.parcel_id,
"full_address_text": person.address.full_address_text,
"latitude": person.address.latitude,
"longitude": person.address.longitude,
}
address_data = AddressOut.model_validate(person.address)
person_data = {
"id": person.id,
"id_uuid": str(person.id_uuid) if person.id_uuid else None,
@@ -343,14 +331,10 @@ async def update_my_person(
raise HTTPException(status_code=400, detail="No Person record found. Complete KYC first.")
# ── Update Person fields ──
update_dict = update_data.dict(exclude_unset=True)
update_dict = update_data.model_dump(exclude_unset=True)
person_fields = ["first_name", "last_name", "phone", "mothers_last_name",
"mothers_first_name", "birth_place", "birth_date"]
address_fields = ["address_zip", "address_city", "address_street_name",
"address_street_type", "address_house_number",
"address_stairwell", "address_floor", "address_door",
"address_hrsz"]
for field in person_fields:
if field in update_dict and update_dict[field] is not None:
@@ -368,21 +352,21 @@ async def update_my_person(
person.identity_docs = update_dict["identity_docs"]
flag_modified(person, "identity_docs")
# ── Update Address fields ──
has_address_update = any(f in update_dict for f in address_fields)
if has_address_update:
# ── Update Address fields (via AddressIn) ──
addr_data = update_data.address
if addr_data is not None:
# Use GeoService to get or create address
addr_id = await GeoService.get_or_create_full_address(
db,
zip_code=update_dict.get("address_zip"),
city=update_dict.get("address_city"),
street_name=update_dict.get("address_street_name"),
street_type=update_dict.get("address_street_type"),
house_number=update_dict.get("address_house_number"),
stairwell=update_dict.get("address_stairwell"),
floor=update_dict.get("address_floor"),
door=update_dict.get("address_door"),
parcel_id=update_dict.get("address_hrsz"),
zip_code=addr_data.zip,
city=addr_data.city,
street_name=addr_data.street_name,
street_type=addr_data.street_type,
house_number=addr_data.house_number,
stairwell=addr_data.stairwell,
floor=addr_data.floor,
door=addr_data.door,
parcel_id=addr_data.parcel_id,
)
if addr_id:
person.address_id = addr_id

View File

@@ -103,6 +103,8 @@ class Organization(Base):
# --- 🏢 ALAPADATOK (MEGŐRIZVE) ---
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
billing_address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
notification_address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
is_anonymized: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"))
anonymized_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
@@ -122,14 +124,6 @@ class Organization(Base):
# Business segment for scope-based admin access (e.g., "Fleet", "Dealer", "Service")
segment: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
address_zip: Mapped[Optional[str]] = mapped_column(String(10))
address_city: Mapped[Optional[str]] = mapped_column(String(100))
address_street_name: Mapped[Optional[str]] = mapped_column(String(150))
address_street_type: Mapped[Optional[str]] = mapped_column(String(50))
address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50))
plus_code: Mapped[Optional[str]] = mapped_column(String(20))
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
@@ -137,20 +131,6 @@ class Organization(Base):
contact_person_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó neve")
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó e-mail címe")
contact_phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="Kapcsolattartó telefonszáma")
# ── P0 CRM: Számlázási cím (Billing Address) ──
billing_zip: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
billing_city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
billing_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
billing_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
billing_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
# ── P0 CRM: Értesítési cím (Notification Address) ──
notification_zip: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
notification_city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
notification_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
notification_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
notification_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
org_type: Mapped[OrgType] = mapped_column(
PG_ENUM(OrgType, name="orgtype", schema="fleet"),
@@ -248,6 +228,26 @@ class Organization(Base):
# Kapcsolat az örök személy rekordhoz
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
# ── P0 ADDRESS RELATIONSHIPS (Code-First Refactor) ──
# Main/primary address
address: Mapped[Optional["Address"]] = relationship(
"Address",
foreign_keys=[address_id],
lazy="selectin"
)
# Billing address
billing_address: Mapped[Optional["Address"]] = relationship(
"Address",
foreign_keys=[billing_address_id],
lazy="selectin"
)
# Notification address
notification_address: Mapped[Optional["Address"]] = relationship(
"Address",
foreign_keys=[notification_address_id],
lazy="selectin"
)
# ── P0 3D CAPABILITY MATRIX: SubscriptionTier relationship ──
subscription_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
@@ -335,18 +335,7 @@ class Branch(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False)
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
# Denormalizált adatok a gyors lekérdezéshez
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
street_name: Mapped[Optional[str]] = mapped_column(String(150))
street_type: Mapped[Optional[str]] = mapped_column(String(50))
house_number: Mapped[Optional[str]] = mapped_column(String(20))
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
floor: Mapped[Optional[str]] = mapped_column(String(20))
door: Mapped[Optional[str]] = mapped_column(String(20))
hrsz: Mapped[Optional[str]] = mapped_column(String(50))
# PostGIS location field for geographic queries
# PostGIS location field for geographic queries (KEPT INTACT)
location: Mapped[Optional[Any]] = mapped_column(
Geometry(geometry_type='POINT', srid=4326),
nullable=True

View File

@@ -177,9 +177,7 @@ class ServiceStaging(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
full_address: Mapped[Optional[str]] = mapped_column(String)
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
raw_data: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
@@ -193,6 +191,9 @@ class ServiceStaging(Base):
status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Kapcsolat a címhez
address: Mapped[Optional["Address"]] = relationship("Address", lazy="selectin")
class DiscoveryParameter(Base):
""" Robot vezérlési paraméterek adminból. """
__tablename__ = "discovery_parameters"

View File

@@ -0,0 +1,60 @@
"""
Unified Address Pydantic Schemas (P0 Refactoring).
AddressIn: For creation/updates — matches system.addresses columns.
AddressOut: For responses — returns the full Address object including id,
zip/city resolved via the postal_code relationship.
Usage:
from app.schemas.address import AddressIn, AddressOut
"""
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional
from datetime import datetime
class AddressIn(BaseModel):
"""Unified address input schema for creation and updates.
Maps to system.addresses table columns.
The `zip` and `city` fields are used to look up / create a
system.geo_postal_codes record; the resulting FK is stored as
postal_code_id on the Address record.
"""
zip: Optional[str] = Field(None, max_length=10, description="Irányítószám (postal code)")
city: Optional[str] = Field(None, max_length=100, description="Város")
street_name: Optional[str] = Field(None, max_length=200, description="Utca neve")
street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (utca, út, tér, stb.)")
house_number: Optional[str] = Field(None, max_length=50, description="Házszám")
stairwell: Optional[str] = Field(None, max_length=20, description="Lépcsőház")
floor: Optional[str] = Field(None, max_length=20, description="Emelet")
door: Optional[str] = Field(None, max_length=20, description="Ajtó")
parcel_id: Optional[str] = Field(None, max_length=50, description="Helyrajzi szám / parcella ID")
full_address_text: Optional[str] = Field(None, description="Teljes cím szövegesen")
latitude: Optional[float] = Field(None, description="GPS szélesség")
longitude: Optional[float] = Field(None, description="GPS hosszúság")
class AddressOut(BaseModel):
"""Unified address output schema for API responses.
Includes the full Address object data, with zip/city resolved
via the postal_code relationship.
"""
id: str # UUID as string for JSON serialization
zip: Optional[str] = None
city: Optional[str] = None
street_name: Optional[str] = None
street_type: Optional[str] = None
house_number: Optional[str] = None
stairwell: Optional[str] = None
floor: Optional[str] = None
door: Optional[str] = None
parcel_id: Optional[str] = None
full_address_text: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -2,6 +2,8 @@ from pydantic import BaseModel, Field, ConfigDict, model_validator
from typing import Optional, List, Any
from datetime import datetime
from app.schemas.address import AddressIn, AddressOut
# =============================================================================
# Organization Member Schemas (P0: Full CRUD for Garage Employees)
@@ -101,16 +103,8 @@ class CorpOnboardIn(BaseModel):
language: str = "hu"
default_currency: str = "HUF"
# --- ATOMIZÁLT CÍM (Modell szinkron) ---
address_zip: str
address_city: str
address_street_name: str
address_street_type: str
address_house_number: str
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
# --- P0 REFACTORED: Unified address via AddressIn ---
address: AddressIn = Field(..., description="Székhely címe (AddressIn struktúrában)")
contacts: List[ContactCreate] = []
@@ -132,13 +126,8 @@ class OrganizationUpdate(BaseModel):
name: Optional[str] = None
full_name: Optional[str] = None
tax_number: Optional[str] = None
# ── Cím adatok (Address fields) ──
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_hrsz: Optional[str] = None
# ── P0 REFACTORED: Unified address via AddressIn ──
address: Optional[AddressIn] = None
# ── Crowdsourced search fields ──
aliases: Optional[List[str]] = None
tags: Optional[List[str]] = None
@@ -170,12 +159,7 @@ class OrganizationResponse(BaseModel):
external_integration_config: Optional[Any] = None
aliases: List[str] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list)
# ── Cím adatok (Address fields) ──
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_hrsz: Optional[str] = None
# ── P0 REFACTORED: Unified address via AddressOut ──
address: Optional[AddressOut] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -6,6 +6,12 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
- street -> address_street_name, address_street_type, address_house_number
- A ProviderSearchResult is tartalmazza az atomizált címmezőket.
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): Egységes AddressIn/AddressOut.
- ProviderSearchResult: address_detail: Optional[AddressOut] a meglévő flat mezők mellett
- ProviderQuickAddIn: address_detail: Optional[AddressIn] a meglévő flat mezők mellett
- ProviderUpdateIn: address_detail: Optional[AddressIn] a meglévő flat mezők mellett
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK a backward compatibility miatt
4-Level Category Architecture (2026-06-17):
===========================================
- CategoryTreeNode: Hierarchikus fa struktúra a frontend checkboxaihoz
@@ -15,6 +21,7 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
from app.schemas.address import AddressIn, AddressOut
# ──────────────────────────────────────────────
@@ -97,6 +104,11 @@ class ProviderSearchResult(BaseModel):
- address_street_name, address_street_type, address_house_number
- A frontend ezeket intelligensen fűzi össze megjelenítéskor.
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
- address_detail: Optional[AddressOut] — egységes cím objektum.
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
FEATURE (2026-06-17): categories mező.
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
@@ -122,6 +134,8 @@ class ProviderSearchResult(BaseModel):
rating: Optional[float] = None
supported_vehicle_classes: Optional[List[str]] = None
specializations: Optional[dict] = None
# P3: Egységes cím objektum (a flat mezők mellett)
address_detail: Optional[AddressOut] = None
model_config = ConfigDict(from_attributes=True)
@@ -140,6 +154,11 @@ class ProviderQuickAddIn(BaseModel):
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
- address_detail: Optional[AddressIn] — egységes cím objektum.
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
- A 4-level kategória rendszer bevezetésével a frontend a category_ids
tömböt használja a kategóriák kiválasztására, nem a régi category_id-t.
@@ -167,6 +186,8 @@ class ProviderQuickAddIn(BaseModel):
specializations: Optional[dict] = Field(
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
)
# P3: Egységes cím objektum (a flat mezők mellett)
address_detail: Optional[AddressIn] = None
class ProviderQuickAddResponse(BaseModel):
@@ -185,6 +206,11 @@ class ProviderUpdateIn(BaseModel):
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
- Tartalmazza a contact_phone, contact_email, website és tags mezőket is.
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
- address_detail: Optional[AddressIn] — egységes cím objektum.
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
4-Level Category (2026-06-17):
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3)
- new_tags: A user által gépelt, új (még nem létező) címkék listája
@@ -212,6 +238,8 @@ class ProviderUpdateIn(BaseModel):
specializations: Optional[dict] = Field(
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
)
# P3: Egységes cím objektum (a flat mezők mellett)
address_detail: Optional[AddressIn] = None
class ProviderUpdateResponse(BaseModel):

View File

@@ -4,24 +4,7 @@ from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional, Any, Dict
from datetime import date, datetime
class AddressResponse(BaseModel):
"""Beágyazott cím adatok a PersonResponse számára.
A mezőnevek address_ előtaggal egyeznek a frontend által várt PersonUpdate formátummal."""
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
full_address_text: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
model_config = ConfigDict(from_attributes=True)
from app.schemas.address import AddressIn, AddressOut
class PersonResponse(BaseModel):
@@ -38,7 +21,7 @@ class PersonResponse(BaseModel):
identity_docs: Optional[Any] = None
ice_contact: Optional[Any] = None
is_active: bool = True
address: Optional[AddressResponse] = None
address: Optional[AddressOut] = None
model_config = ConfigDict(from_attributes=True)
@@ -90,16 +73,8 @@ class PersonUpdate(BaseModel):
# Okmány adatok (identity_docs)
identity_docs: Optional[Any] = None
# Cím adatok
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
# Cím adatok — egységes AddressIn séma
address: Optional[AddressIn] = None
class ChangePasswordRequest(BaseModel):

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(