268 lines
9.1 KiB
Python
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()
|