refaktor címtár
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
60
backend/app/schemas/address.py
Normal file
60
backend/app/schemas/address.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
267
backend/app/services/address_manager.py
Normal file
267
backend/app/services/address_manager.py
Normal 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()
|
||||
140
backend/app/services/address_service.py
Normal file
140
backend/app/services/address_service.py
Normal 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)
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user