refaktor címtár
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user