refaktor címtár

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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