""" 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()