757 lines
27 KiB
Python
757 lines
27 KiB
Python
"""Aggregator service orchestrating providers, cache and domain rules."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
from contextlib import AbstractAsyncContextManager
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
from typing import Protocol
|
|
from uuid import uuid4
|
|
|
|
import structlog
|
|
|
|
from app.adapters.address_suggestions.base import (
|
|
AddressSuggestionClientError,
|
|
AddressSuggestionProvider,
|
|
AddressSuggestionRequestError,
|
|
)
|
|
from app.adapters.delivery_providers.base import (
|
|
DeliveryProvider,
|
|
ProviderClientError,
|
|
ProviderRequestError,
|
|
)
|
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
|
CDEKOrderRegistrationResult,
|
|
)
|
|
from app.adapters.tbank.base import (
|
|
TBankPaymentAdapterError,
|
|
TBankPaymentNotificationTokenError,
|
|
TBankPaymentRequestError,
|
|
)
|
|
from app.domain.payment_notifications import (
|
|
TBankPaymentNotificationAction,
|
|
resolve_tbank_payment_notification_action,
|
|
)
|
|
from app.domain.price import (
|
|
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
|
DEFAULT_WEIGHT_ROUND_SCALE,
|
|
NormalizedDeliveryRequest,
|
|
calculate_expected_payment_amount_kopecks,
|
|
filter_and_sort_prices,
|
|
is_init_payment_price_valid,
|
|
normalize_delivery_request,
|
|
)
|
|
from app.repositories.order import OrderData
|
|
from app.schemas.payment import (
|
|
InitPaymentRequest,
|
|
InitPaymentResponse,
|
|
TBankPaymentNotification,
|
|
)
|
|
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
|
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
class AggregatorServiceError(RuntimeError):
|
|
"""Base exception for AggregatorService failures."""
|
|
|
|
|
|
class InvalidDeliveryRequestError(AggregatorServiceError):
|
|
"""Raised when provider rejects delivery request as invalid."""
|
|
|
|
|
|
class UnsupportedAddressSuggestionCountryError(AggregatorServiceError):
|
|
"""Raised when address suggestions are not configured for a country."""
|
|
|
|
|
|
class InvalidAddressSuggestRequestError(AggregatorServiceError):
|
|
"""Raised when provider rejects address suggestion payload."""
|
|
|
|
|
|
class AddressSuggestionsUnavailableError(AggregatorServiceError):
|
|
"""Raised when address suggestions cannot be completed."""
|
|
|
|
|
|
class InvalidInitPaymentRequestError(AggregatorServiceError):
|
|
"""Raised when provider rejects payment init payload as invalid."""
|
|
|
|
|
|
class InitPaymentUnavailableError(AggregatorServiceError):
|
|
"""Raised when payment initialization cannot be completed."""
|
|
|
|
|
|
class InvalidTBankPaymentNotificationError(AggregatorServiceError):
|
|
"""Raised when a TBank payment notification is invalid."""
|
|
|
|
|
|
class TBankPaymentNotificationProcessingError(AggregatorServiceError):
|
|
"""Raised when a TBank payment notification cannot be processed."""
|
|
|
|
|
|
class PriceCacheProtocol(Protocol):
|
|
async def get(self, key: str) -> object | None: ...
|
|
|
|
async def set(self, key: str, value: object, ttl: int | None = None) -> None: ...
|
|
|
|
|
|
class PaymentAdapterProtocol(Protocol):
|
|
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: ...
|
|
|
|
def verify_payment_notification(
|
|
self,
|
|
notification: TBankPaymentNotification,
|
|
) -> None: ...
|
|
|
|
|
|
class PaymentPriceValidationAdapterProtocol(Protocol):
|
|
async def get_payment_price(
|
|
self,
|
|
request: InitPaymentRequest,
|
|
) -> DeliveryPrice | None: ...
|
|
|
|
|
|
class OrderRegistrationAdapterProtocol(Protocol):
|
|
async def register_order(
|
|
self, request: InitPaymentRequest, order_uuid: str
|
|
) -> CDEKOrderRegistrationResult: ...
|
|
|
|
|
|
class OrderRepositoryProtocol(Protocol):
|
|
def session(self) -> AbstractAsyncContextManager[object]: ...
|
|
|
|
async def create_order(self, session: object, order_data: OrderData) -> object: ...
|
|
|
|
async def get_order_by_order_uuid(
|
|
self,
|
|
session: object,
|
|
order_uuid: str,
|
|
) -> object | None: ...
|
|
|
|
async def mark_payment_status(
|
|
self,
|
|
session: object,
|
|
order_uuid: str,
|
|
status: str,
|
|
payment_id: int,
|
|
) -> object | None: ...
|
|
|
|
async def mark_cdek_order_registered(
|
|
self,
|
|
session: object,
|
|
order_uuid: str,
|
|
cdek_order_uuid: str,
|
|
) -> object | None: ...
|
|
|
|
|
|
class EmailSenderProtocol(Protocol):
|
|
async def send_email(
|
|
self,
|
|
*,
|
|
to: str,
|
|
subject: str,
|
|
body: str,
|
|
) -> None: ...
|
|
|
|
|
|
class FilterAndSortPricesFn(Protocol):
|
|
def __call__(
|
|
self,
|
|
prices: Iterable[object],
|
|
*,
|
|
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
|
parcel_type: object | None = None,
|
|
) -> list[object]: ...
|
|
|
|
|
|
class AggregatorService:
|
|
def __init__(
|
|
self,
|
|
providers: Sequence[DeliveryProvider],
|
|
cache: PriceCacheProtocol | None = None,
|
|
payment_adapter: PaymentAdapterProtocol | None = None,
|
|
payment_price_validation_adapter: (
|
|
PaymentPriceValidationAdapterProtocol | None
|
|
) = None,
|
|
order_repository: OrderRepositoryProtocol | None = None,
|
|
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
|
|
email_sender: EmailSenderProtocol | None = None,
|
|
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
|
|
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
|
|
*,
|
|
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
|
|
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
|
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
|
|
order_uuid_factory: Callable[[], str] = lambda: str(uuid4()),
|
|
) -> None:
|
|
self._providers = tuple(providers)
|
|
self._cache = cache
|
|
self._payment_adapter = payment_adapter
|
|
self._payment_price_validation_adapter = payment_price_validation_adapter
|
|
self._order_repository = order_repository
|
|
self._order_registration_adapter = order_registration_adapter
|
|
self._email_sender = email_sender
|
|
self._weight_round_scale = weight_round_scale
|
|
self._provider_price_multiplier = provider_price_multiplier
|
|
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
|
self._order_uuid_factory = order_uuid_factory
|
|
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
|
|
provider.name: provider for provider in address_suggestion_providers
|
|
}
|
|
self._address_suggestion_country_to_provider = (
|
|
address_suggestion_country_to_provider or {}
|
|
)
|
|
|
|
async def get_all_prices(
|
|
self, request: DeliveryCalculationRequest
|
|
) -> list[DeliveryPrice]:
|
|
normalized_request = normalize_delivery_request(
|
|
request, weight_round_scale=self._weight_round_scale
|
|
)
|
|
provider_request = self._to_provider_request(normalized_request)
|
|
|
|
provider_results = await asyncio.gather(
|
|
*(
|
|
self._get_provider_prices(
|
|
provider=provider,
|
|
request=provider_request,
|
|
cache_key=self._build_cache_key(
|
|
provider_name=provider.name,
|
|
request=normalized_request,
|
|
),
|
|
)
|
|
for provider in self._providers
|
|
),
|
|
return_exceptions=True,
|
|
)
|
|
|
|
successful_results = [
|
|
price
|
|
for provider_prices in provider_results
|
|
if isinstance(provider_prices, list)
|
|
for price in provider_prices
|
|
]
|
|
provider_errors = [
|
|
error
|
|
for error in provider_results
|
|
if isinstance(error, Exception)
|
|
]
|
|
if (
|
|
not successful_results
|
|
and provider_errors
|
|
and any(isinstance(error, ProviderRequestError) for error in provider_errors)
|
|
):
|
|
raise InvalidDeliveryRequestError(
|
|
"Delivery request is invalid for configured providers."
|
|
)
|
|
filtered_and_sorted = self._filter_and_sort_prices(
|
|
successful_results,
|
|
price_multiplier=self._provider_price_multiplier,
|
|
parcel_type=request.parcel_type,
|
|
)
|
|
return [self._coerce_delivery_price(price) for price in filtered_and_sorted]
|
|
|
|
async def suggest_addresses(
|
|
self, request: AddressSuggestRequest
|
|
) -> list[AddressSuggestion]:
|
|
provider = self._resolve_address_suggestion_provider(request.country_code)
|
|
|
|
try:
|
|
suggestions = await provider.suggest(request)
|
|
except AddressSuggestionRequestError as exc:
|
|
raise InvalidAddressSuggestRequestError(
|
|
"Address suggestion request is invalid for the configured provider."
|
|
) from exc
|
|
except AddressSuggestionClientError as exc:
|
|
raise AddressSuggestionsUnavailableError(
|
|
"Address suggestions are temporarily unavailable."
|
|
) from exc
|
|
|
|
return [self._coerce_address_suggestion(item) for item in suggestions]
|
|
|
|
async def init_payment(self, request: InitPaymentRequest) -> InitPaymentResponse:
|
|
if self._payment_adapter is None:
|
|
raise InitPaymentUnavailableError("Payment adapter is not configured.")
|
|
|
|
order_uuid = self._order_uuid_factory()
|
|
|
|
await self._validate_init_payment_price(request, order_uuid)
|
|
|
|
try:
|
|
payment_url = await self._payment_adapter.create_payment_link(
|
|
order_uuid=order_uuid,
|
|
amount_kopecks=request.system_data.tariff.price,
|
|
)
|
|
except TBankPaymentRequestError as exc:
|
|
logger.exception(
|
|
"payment_init_rejected",
|
|
order_uuid=order_uuid,
|
|
provider_status_code=exc.status_code,
|
|
provider_error_code=exc.error_code,
|
|
provider_error_message=exc.provider_message,
|
|
provider_error_details=exc.details,
|
|
)
|
|
raise InvalidInitPaymentRequestError(
|
|
"Payment init request is invalid for the configured provider."
|
|
) from exc
|
|
except TBankPaymentAdapterError as exc:
|
|
raise InitPaymentUnavailableError(
|
|
"Payment initialization is temporarily unavailable."
|
|
) from exc
|
|
except Exception as exc:
|
|
raise InitPaymentUnavailableError(
|
|
"Payment initialization is temporarily unavailable."
|
|
) from exc
|
|
|
|
await self._persist_order(
|
|
request=request, order_uuid=order_uuid, payment_url=payment_url
|
|
)
|
|
return InitPaymentResponse(payment_url=payment_url)
|
|
|
|
async def _validate_init_payment_price(
|
|
self,
|
|
request: InitPaymentRequest,
|
|
order_uuid: str,
|
|
) -> None:
|
|
if self._payment_price_validation_adapter is None:
|
|
raise InitPaymentUnavailableError(
|
|
"Payment price validation adapter is not configured."
|
|
)
|
|
|
|
tariff_code = request.system_data.tariff.tariff_code
|
|
requested_price = request.system_data.tariff.price
|
|
try:
|
|
provider_price = await self._payment_price_validation_adapter.get_payment_price(
|
|
request
|
|
)
|
|
except ProviderRequestError as exc:
|
|
logger.warning(
|
|
"init_payment_price_validation_request_rejected",
|
|
order_uuid=order_uuid,
|
|
tariff_code=tariff_code,
|
|
requested_price_kopecks=requested_price,
|
|
error=str(exc),
|
|
)
|
|
raise InvalidInitPaymentRequestError(
|
|
"Payment init request is invalid for CDEK price validation."
|
|
) from exc
|
|
except ProviderClientError as exc:
|
|
logger.warning(
|
|
"init_payment_price_validation_unavailable",
|
|
order_uuid=order_uuid,
|
|
tariff_code=tariff_code,
|
|
requested_price_kopecks=requested_price,
|
|
error=str(exc),
|
|
)
|
|
raise InitPaymentUnavailableError(
|
|
"Payment price validation is temporarily unavailable."
|
|
) from exc
|
|
except Exception as exc:
|
|
logger.exception(
|
|
"init_payment_price_validation_unexpected_error",
|
|
order_uuid=order_uuid,
|
|
tariff_code=tariff_code,
|
|
requested_price_kopecks=requested_price,
|
|
)
|
|
raise InitPaymentUnavailableError(
|
|
"Payment price validation is temporarily unavailable."
|
|
) from exc
|
|
|
|
if provider_price is None:
|
|
logger.warning(
|
|
"init_payment_price_validation_tariff_not_found",
|
|
order_uuid=order_uuid,
|
|
tariff_code=tariff_code,
|
|
requested_price_kopecks=requested_price,
|
|
)
|
|
raise InvalidInitPaymentRequestError(
|
|
"CDEK did not return the requested tariff for payment validation."
|
|
)
|
|
|
|
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
|
|
provider_price,
|
|
price_multiplier=self._provider_price_multiplier,
|
|
)
|
|
if not is_init_payment_price_valid(
|
|
requested_price,
|
|
provider_price,
|
|
price_multiplier=self._provider_price_multiplier,
|
|
):
|
|
logger.warning(
|
|
"init_payment_price_mismatch",
|
|
order_uuid=order_uuid,
|
|
tariff_code=tariff_code,
|
|
requested_price_kopecks=requested_price,
|
|
expected_price_kopecks=expected_amount_kopecks,
|
|
provider_currency=getattr(provider_price, "currency", None),
|
|
provider_price=str(getattr(provider_price, "price", None)),
|
|
)
|
|
raise InvalidInitPaymentRequestError(
|
|
"Payment amount does not match CDEK validated delivery price."
|
|
)
|
|
|
|
async def handle_tbank_payment_notification(
|
|
self,
|
|
notification: TBankPaymentNotification,
|
|
) -> str:
|
|
if self._payment_adapter is None:
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"Payment adapter is not configured."
|
|
)
|
|
|
|
try:
|
|
self._payment_adapter.verify_payment_notification(notification)
|
|
except TBankPaymentNotificationTokenError as exc:
|
|
raise InvalidTBankPaymentNotificationError(
|
|
"TBank payment notification token is invalid."
|
|
) from exc
|
|
except TBankPaymentAdapterError as exc:
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"TBank payment notification verification failed."
|
|
) from exc
|
|
|
|
action = resolve_tbank_payment_notification_action(
|
|
status=notification.Status,
|
|
success=notification.Success,
|
|
error_code=notification.ErrorCode,
|
|
)
|
|
order = await self._load_order_and_mark_payment_status(notification)
|
|
|
|
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
|
|
return "OK"
|
|
|
|
existing_cdek_order_uuid = getattr(order, "cdek_order_uuid", None)
|
|
if existing_cdek_order_uuid:
|
|
return "OK"
|
|
|
|
registration_result = await self._register_cdek_order(
|
|
order, order_uuid=notification.OrderId
|
|
)
|
|
await self._save_cdek_order_uuid(
|
|
order_uuid=notification.OrderId,
|
|
cdek_order_uuid=registration_result.order_uuid,
|
|
)
|
|
await self._send_payment_confirmation_email(
|
|
order, order_uuid=notification.OrderId
|
|
)
|
|
return "OK"
|
|
|
|
async def _send_payment_confirmation_email(
|
|
self,
|
|
order: object,
|
|
*,
|
|
order_uuid: str,
|
|
) -> None:
|
|
if self._email_sender is None or self._order_repository is None:
|
|
return
|
|
|
|
if getattr(order, "payment_email_sent_at", None) is not None:
|
|
return
|
|
|
|
account_email = getattr(order, "account_email", None)
|
|
if account_email is None:
|
|
return
|
|
|
|
try:
|
|
await self._email_sender.send_email(
|
|
to=account_email,
|
|
subject=f"Оплата по заказу {order_uuid} принята",
|
|
body=(
|
|
f"Здравствуйте!\n\n"
|
|
f"Оплата по заказу {order_uuid} успешно принята.\n"
|
|
f"Накладная будет отправлена на этот адрес в ближайшее время.\n\n"
|
|
f"Спасибо за заказ!"
|
|
),
|
|
)
|
|
async with self._order_repository.session() as session:
|
|
await self._order_repository.record_payment_email_sent(
|
|
session,
|
|
order_uuid=order_uuid,
|
|
sent_at=datetime.now(timezone.utc),
|
|
)
|
|
logger.info(
|
|
"payment_confirmation_email_sent",
|
|
order_uuid=order_uuid,
|
|
account_email=account_email,
|
|
)
|
|
except Exception:
|
|
logger.warning(
|
|
"payment_confirmation_email_failed",
|
|
order_uuid=order_uuid,
|
|
account_email=account_email,
|
|
exc_info=True,
|
|
)
|
|
|
|
async def _load_order_and_mark_payment_status(
|
|
self,
|
|
notification: TBankPaymentNotification,
|
|
) -> object:
|
|
if self._order_repository is None:
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"Order repository is not configured."
|
|
)
|
|
|
|
try:
|
|
async with self._order_repository.session() as session:
|
|
order = await self._order_repository.get_order_by_order_uuid(
|
|
session,
|
|
notification.OrderId,
|
|
)
|
|
if order is None:
|
|
logger.warning(
|
|
"tbank_payment_notification_order_not_found",
|
|
order_uuid=notification.OrderId,
|
|
)
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"Order was not found for TBank payment notification."
|
|
)
|
|
updated_order = await self._order_repository.mark_payment_status(
|
|
session,
|
|
notification.OrderId,
|
|
notification.Status,
|
|
notification.PaymentId,
|
|
)
|
|
if updated_order is None:
|
|
logger.warning(
|
|
"tbank_payment_notification_order_not_found",
|
|
order_uuid=notification.OrderId,
|
|
)
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"Order was not found for TBank payment notification."
|
|
)
|
|
return order
|
|
except TBankPaymentNotificationProcessingError:
|
|
raise
|
|
except Exception as exc:
|
|
logger.exception(
|
|
"tbank_payment_notification_order_update_failed",
|
|
order_uuid=notification.OrderId,
|
|
payment_status=notification.Status,
|
|
tbank_payment_id=notification.PaymentId,
|
|
)
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"TBank payment notification order update failed."
|
|
) from exc
|
|
|
|
async def _register_cdek_order(
|
|
self, order: object, *, order_uuid: str
|
|
) -> CDEKOrderRegistrationResult:
|
|
if self._order_registration_adapter is None:
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"CDEK order registration adapter is not configured."
|
|
)
|
|
|
|
try:
|
|
request = self._to_init_payment_request_from_order(order)
|
|
return await self._order_registration_adapter.register_order(
|
|
request, order_uuid
|
|
)
|
|
except Exception as exc:
|
|
logger.exception(
|
|
"cdek_order_registration_failed",
|
|
order_uuid=getattr(order, "order_uuid", None),
|
|
)
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"CDEK order registration failed."
|
|
) from exc
|
|
|
|
async def _save_cdek_order_uuid(
|
|
self,
|
|
*,
|
|
order_uuid: str,
|
|
cdek_order_uuid: str,
|
|
) -> None:
|
|
if self._order_repository is None:
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"Order repository is not configured."
|
|
)
|
|
|
|
try:
|
|
async with self._order_repository.session() as session:
|
|
order = await self._order_repository.mark_cdek_order_registered(
|
|
session,
|
|
order_uuid,
|
|
cdek_order_uuid,
|
|
)
|
|
if order is None:
|
|
logger.warning(
|
|
"cdek_order_uuid_order_not_found",
|
|
order_uuid=order_uuid,
|
|
cdek_order_uuid=cdek_order_uuid,
|
|
)
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"Order was not found while saving CDEK order UUID."
|
|
)
|
|
except TBankPaymentNotificationProcessingError:
|
|
raise
|
|
except Exception as exc:
|
|
logger.exception(
|
|
"cdek_order_uuid_persistence_failed",
|
|
order_uuid=order_uuid,
|
|
cdek_order_uuid=cdek_order_uuid,
|
|
)
|
|
raise TBankPaymentNotificationProcessingError(
|
|
"CDEK order UUID persistence failed."
|
|
) from exc
|
|
|
|
async def _persist_order(
|
|
self,
|
|
*,
|
|
request: InitPaymentRequest,
|
|
order_uuid: str,
|
|
payment_url: str,
|
|
) -> None:
|
|
if self._order_repository is None:
|
|
return
|
|
|
|
try:
|
|
async with self._order_repository.session() as session:
|
|
await self._order_repository.create_order(
|
|
session,
|
|
self._to_order_data(
|
|
request=request,
|
|
order_uuid=order_uuid,
|
|
payment_url=payment_url,
|
|
),
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"order_persistence_failed",
|
|
order_uuid=order_uuid,
|
|
)
|
|
|
|
@staticmethod
|
|
def _to_order_data(
|
|
*,
|
|
request: InitPaymentRequest,
|
|
order_uuid: str,
|
|
payment_url: str,
|
|
) -> OrderData:
|
|
return OrderData(
|
|
order_uuid=order_uuid,
|
|
payment_url=payment_url,
|
|
price=request.system_data.tariff.price,
|
|
tariff_code=request.system_data.tariff.tariff_code,
|
|
account_email=request.account_email,
|
|
payload=request.model_dump(mode="json", by_alias=True),
|
|
)
|
|
|
|
@staticmethod
|
|
def _to_init_payment_request_from_order(order: object) -> InitPaymentRequest:
|
|
payload = getattr(order, "payload")
|
|
return InitPaymentRequest.model_validate(payload)
|
|
|
|
async def _get_provider_prices(
|
|
self,
|
|
*,
|
|
provider: DeliveryProvider,
|
|
request: DeliveryCalculationRequest,
|
|
cache_key: str,
|
|
) -> list[DeliveryPrice]:
|
|
cached_prices = await self._get_cached_prices(cache_key)
|
|
if cached_prices is not None:
|
|
return cached_prices
|
|
|
|
fresh_prices = await provider.get_prices(request)
|
|
await self._set_cached_prices(
|
|
cache_key,
|
|
fresh_prices,
|
|
ttl=getattr(provider, "cache_ttl_seconds", None),
|
|
)
|
|
return fresh_prices
|
|
|
|
async def _get_cached_prices(self, cache_key: str) -> list[DeliveryPrice] | None:
|
|
if self._cache is None:
|
|
return None
|
|
try:
|
|
payload = await self._cache.get(cache_key)
|
|
except Exception:
|
|
return None
|
|
if payload is None:
|
|
return None
|
|
try:
|
|
return self._coerce_delivery_prices(payload)
|
|
except Exception:
|
|
return None
|
|
|
|
async def _set_cached_prices(
|
|
self,
|
|
cache_key: str,
|
|
payload: list[DeliveryPrice],
|
|
*,
|
|
ttl: int | None,
|
|
) -> None:
|
|
if self._cache is None:
|
|
return
|
|
try:
|
|
await self._cache.set(cache_key, payload, ttl=ttl)
|
|
except Exception:
|
|
return
|
|
|
|
@staticmethod
|
|
def _to_provider_request(
|
|
request: NormalizedDeliveryRequest,
|
|
) -> DeliveryCalculationRequest:
|
|
return DeliveryCalculationRequest(
|
|
entity=request.entity,
|
|
from_city=request.from_city,
|
|
to_city=request.to_city,
|
|
weight_kg=request.weight_kg,
|
|
length_cm=request.length_cm,
|
|
width_cm=request.width_cm,
|
|
height_cm=request.height_cm,
|
|
)
|
|
|
|
@staticmethod
|
|
def _build_cache_key(provider_name: str, request: NormalizedDeliveryRequest) -> str:
|
|
cache_payload = {
|
|
"provider": provider_name,
|
|
"entity": request.entity,
|
|
"from_city": request.from_city,
|
|
"to_city": request.to_city,
|
|
"weight_kg": str(request.weight_kg),
|
|
"length_cm": str(request.length_cm),
|
|
"width_cm": str(request.width_cm),
|
|
"height_cm": str(request.height_cm),
|
|
}
|
|
serialized_payload = json.dumps(
|
|
cache_payload,
|
|
ensure_ascii=True,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)
|
|
cache_hash = hashlib.sha256(serialized_payload.encode("utf-8")).hexdigest()
|
|
return f"delivery-price:{provider_name}:{cache_hash}"
|
|
|
|
@staticmethod
|
|
def _coerce_delivery_price(value: object) -> DeliveryPrice:
|
|
return DeliveryPrice.model_validate(value, from_attributes=True)
|
|
|
|
@classmethod
|
|
def _coerce_delivery_prices(cls, value: object) -> list[DeliveryPrice]:
|
|
if not isinstance(value, list):
|
|
raise TypeError("Cached delivery prices must be a list.")
|
|
return [cls._coerce_delivery_price(item) for item in value]
|
|
|
|
@staticmethod
|
|
def _coerce_address_suggestion(value: object) -> AddressSuggestion:
|
|
return AddressSuggestion.model_validate(value, from_attributes=True)
|
|
|
|
def _resolve_address_suggestion_provider(
|
|
self, country_code: str
|
|
) -> AddressSuggestionProvider:
|
|
provider_id = self._address_suggestion_country_to_provider.get(country_code)
|
|
if provider_id is None:
|
|
raise UnsupportedAddressSuggestionCountryError(
|
|
"Address suggestions are not configured for the requested country."
|
|
)
|
|
|
|
provider = self._address_suggestion_providers.get(provider_id)
|
|
if provider is None:
|
|
raise UnsupportedAddressSuggestionCountryError(
|
|
"Address suggestions are not configured for the requested country."
|
|
)
|
|
return provider
|