Files
service-finder/backend/app/services/address_manager.py
2026-07-01 19:49:58 +00:00

268 lines
9.1 KiB
Python

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