020 update city code resolving
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.schemas.request import DeliveryRequest
|
||||
from app.schemas.request import DeliveryCalculationRequest
|
||||
from app.schemas.response import DeliveryPrice
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ class DeliveryProvider(ABC):
|
||||
name: str
|
||||
|
||||
@abstractmethod
|
||||
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]:
|
||||
async def get_prices(
|
||||
self, request: DeliveryCalculationRequest
|
||||
) -> list[DeliveryPrice]:
|
||||
"""Fetch provider tariffs for a delivery request."""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -15,9 +15,10 @@ from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
map_cdek_order_request,
|
||||
map_cdek_order_response,
|
||||
)
|
||||
from app.cities import cities_map
|
||||
from app.config import AdapterConfig
|
||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||
from app.schemas.request import DeliveryRequest
|
||||
from app.schemas.request import DeliveryCalculationRequest
|
||||
from app.schemas.response import DeliveryPrice
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,16 +46,16 @@ class CDEKClient:
|
||||
self._http_client = http_client
|
||||
self._auth_client = auth_client
|
||||
normalized_base_url = base_url.rstrip("/")
|
||||
self._city_lookup_url = f"{normalized_base_url}/location/suggest/cities"
|
||||
self._tariff_url = f"{normalized_base_url}/calculator/tarifflist"
|
||||
self._orders_url = f"{normalized_base_url}/orders"
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._retry_attempts = retry_attempts
|
||||
self._retry_backoff_seconds = retry_backoff_seconds
|
||||
self._sleep = sleep
|
||||
self._city_code_cache: dict[str, int] = {}
|
||||
|
||||
async def get_raw_price(self, request: DeliveryRequest) -> dict[str, Any]:
|
||||
async def get_raw_price(
|
||||
self, request: DeliveryCalculationRequest
|
||||
) -> dict[str, Any]:
|
||||
payload = await self._build_payload(request)
|
||||
for attempt in range(self._retry_attempts + 1):
|
||||
try:
|
||||
@@ -89,7 +90,6 @@ class CDEKClient:
|
||||
|
||||
if not isinstance(raw_payload, dict):
|
||||
raise CDEKClientError("CDEK tariff payload must be a JSON object.")
|
||||
log.info(f"Founded {len(response.json())} tarrifs")
|
||||
return raw_payload
|
||||
|
||||
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
||||
@@ -157,15 +157,11 @@ class CDEKClient:
|
||||
def _should_retry(status_code: int) -> bool:
|
||||
return status_code == 429 or status_code >= 500
|
||||
|
||||
async def _build_payload(self, request: DeliveryRequest) -> dict[str, Any]:
|
||||
from_city_code = await self._resolve_city_code(
|
||||
request.from_city,
|
||||
country_code=request.country_code,
|
||||
)
|
||||
to_city_code = await self._resolve_city_code(
|
||||
request.to_city,
|
||||
country_code=request.country_code,
|
||||
)
|
||||
async def _build_payload(
|
||||
self, request: DeliveryCalculationRequest
|
||||
) -> dict[str, Any]:
|
||||
from_city_code = self._get_cdek_city_code(request.from_city)
|
||||
to_city_code = self._get_cdek_city_code(request.to_city)
|
||||
return {
|
||||
"type": 2,
|
||||
"from_location": {"code": from_city_code},
|
||||
@@ -180,76 +176,32 @@ class CDEKClient:
|
||||
],
|
||||
}
|
||||
|
||||
async def _resolve_city_code(self, city: str, *, country_code: str | None) -> int:
|
||||
normalized_city = city.strip().casefold()
|
||||
normalized_country_code = (
|
||||
country_code.strip().upper() if country_code is not None else ""
|
||||
)
|
||||
cache_key = f"{normalized_country_code}:{normalized_city}"
|
||||
cached_code = self._city_code_cache.get(cache_key)
|
||||
if cached_code is not None:
|
||||
return cached_code
|
||||
|
||||
for attempt in range(self._retry_attempts + 1):
|
||||
try:
|
||||
token = await self._auth_client.get_access_token()
|
||||
params = {"name": city}
|
||||
if normalized_country_code:
|
||||
params["country_code"] = normalized_country_code
|
||||
response = await self._http_client.get(
|
||||
self._city_lookup_url,
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
log.info(f"Founded cities: {response.json()}")
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
if attempt < self._retry_attempts:
|
||||
await self._sleep(self._retry_delay(attempt))
|
||||
continue
|
||||
raise CDEKClientError(
|
||||
"CDEK city lookup request failed after retry attempts."
|
||||
) from exc
|
||||
|
||||
if self._should_retry(response.status_code):
|
||||
if attempt < self._retry_attempts:
|
||||
await self._sleep(self._retry_delay(attempt))
|
||||
continue
|
||||
raise CDEKClientError(
|
||||
f"CDEK city lookup failed with status {response.status_code}."
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
except (httpx.HTTPError, TypeError, ValueError) as exc:
|
||||
raise CDEKClientError("CDEK city lookup returned invalid payload.") from exc
|
||||
break
|
||||
else:
|
||||
raise CDEKClientError("CDEK city lookup failed unexpectedly.")
|
||||
|
||||
if isinstance(body, dict):
|
||||
first_item = body
|
||||
elif isinstance(body, list) and body:
|
||||
first_item = body[0]
|
||||
elif isinstance(body, list):
|
||||
@staticmethod
|
||||
def _get_cdek_city_code(city_id: int) -> int:
|
||||
city_entry = cities_map.get(str(city_id))
|
||||
if not isinstance(city_entry, dict):
|
||||
raise CDEKRequestError(
|
||||
f"CDEK city lookup returned no matches for '{city}'."
|
||||
f"CDEK city mapping is not configured for city id {city_id}."
|
||||
)
|
||||
|
||||
cdek_data = city_entry.get("cdek")
|
||||
if not isinstance(cdek_data, dict):
|
||||
raise CDEKRequestError(
|
||||
f"CDEK city mapping is not configured for city id {city_id}."
|
||||
)
|
||||
|
||||
raw_city_code = cdek_data.get("code")
|
||||
if raw_city_code is None or isinstance(raw_city_code, bool):
|
||||
raise CDEKRequestError(
|
||||
f"CDEK city code is invalid for city id {city_id}."
|
||||
)
|
||||
else:
|
||||
raise CDEKClientError("CDEK city lookup response must be an object or array.")
|
||||
|
||||
if not isinstance(first_item, dict):
|
||||
raise CDEKClientError("CDEK city lookup response entry must be an object.")
|
||||
raw_city_code = first_item.get("code")
|
||||
if raw_city_code is None:
|
||||
raise CDEKClientError("CDEK city lookup response has no city code.")
|
||||
try:
|
||||
city_code = int(raw_city_code)
|
||||
return int(raw_city_code)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CDEKClientError("CDEK city lookup response city code is invalid.") from exc
|
||||
|
||||
self._city_code_cache[cache_key] = city_code
|
||||
return city_code
|
||||
raise CDEKRequestError(
|
||||
f"CDEK city code is invalid for city id {city_id}."
|
||||
) from exc
|
||||
|
||||
|
||||
class CDEKProvider(DeliveryProvider):
|
||||
@@ -283,7 +235,9 @@ class CDEKProvider(DeliveryProvider):
|
||||
)
|
||||
return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds)
|
||||
|
||||
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]:
|
||||
async def get_prices(
|
||||
self, request: DeliveryCalculationRequest
|
||||
) -> list[DeliveryPrice]:
|
||||
raw_payload = await self._client.get_raw_price(request)
|
||||
return map_cdek_response(raw_payload)
|
||||
|
||||
|
||||
+45659
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ from app.config import Settings
|
||||
from app.controllers.http_client import build_controller_http_client
|
||||
from app.repositories.cache.redis_cache import PriceCache
|
||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||
from app.schemas.request import DeliveryRequest
|
||||
from app.schemas.request import DeliveryCalculationRequest
|
||||
from app.schemas.response import DeliveryPrice
|
||||
from app.services.aggregator import (
|
||||
AggregatorService,
|
||||
@@ -58,7 +58,7 @@ async def get_aggregator_service(request: Request) -> AggregatorService:
|
||||
response_model=list[DeliveryPrice],
|
||||
)
|
||||
async def get_delivery_price(
|
||||
delivery_request: DeliveryRequest,
|
||||
delivery_request: DeliveryCalculationRequest,
|
||||
service: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> list[DeliveryPrice]:
|
||||
try:
|
||||
|
||||
+9
-14
@@ -21,7 +21,6 @@ class RequestLike(Protocol):
|
||||
entity: object
|
||||
from_city: object
|
||||
to_city: object
|
||||
country_code: object
|
||||
weight_kg: object
|
||||
length_cm: object
|
||||
width_cm: object
|
||||
@@ -31,9 +30,8 @@ class RequestLike(Protocol):
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NormalizedDeliveryRequest:
|
||||
entity: str
|
||||
from_city: str
|
||||
to_city: str
|
||||
country_code: str | None
|
||||
from_city: int
|
||||
to_city: int
|
||||
weight_kg: Decimal
|
||||
length_cm: Decimal
|
||||
width_cm: Decimal
|
||||
@@ -61,9 +59,8 @@ def normalize_delivery_request(
|
||||
|
||||
return NormalizedDeliveryRequest(
|
||||
entity=str(_get_required_attr(request, "entity")),
|
||||
from_city=_normalize_text(_get_required_attr(request, "from_city")),
|
||||
to_city=_normalize_text(_get_required_attr(request, "to_city")),
|
||||
country_code=_normalize_country_code(_get_optional_attr(request, "country_code")),
|
||||
from_city=_normalize_city_id(_get_required_attr(request, "from_city")),
|
||||
to_city=_normalize_city_id(_get_required_attr(request, "to_city")),
|
||||
weight_kg=_normalize_weight(
|
||||
_to_decimal(_get_required_attr(request, "weight_kg")),
|
||||
normalized_weight_scale,
|
||||
@@ -219,13 +216,11 @@ def _normalize_text(value: object) -> str:
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _normalize_country_code(value: object) -> str | None:
|
||||
if value is _MISSING or value is None:
|
||||
return None
|
||||
normalized = str(value).strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized
|
||||
def _normalize_city_id(value: object) -> int:
|
||||
city_id = _try_to_int(value)
|
||||
if city_id is None:
|
||||
raise ValueError(f"City identifier must be an integer: {value!r}")
|
||||
return city_id
|
||||
|
||||
|
||||
def _clamp_scale(scale: int) -> int:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, StrictInt
|
||||
|
||||
|
||||
class DeliveryEntity(StrEnum):
|
||||
@@ -15,11 +15,10 @@ class ParcelType(StrEnum):
|
||||
PARCEL = "parcel"
|
||||
|
||||
|
||||
class DeliveryRequest(BaseModel):
|
||||
class DeliveryCalculationRequest(BaseModel):
|
||||
entity: DeliveryEntity
|
||||
from_city: str = Field(min_length=1)
|
||||
to_city: str = Field(min_length=1)
|
||||
country_code: str | None = None
|
||||
from_city: StrictInt
|
||||
to_city: StrictInt
|
||||
weight_kg: float = Field(gt=0)
|
||||
length_cm: float = Field(gt=0)
|
||||
width_cm: float = Field(gt=0)
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.domain.price import (
|
||||
normalize_delivery_request,
|
||||
)
|
||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||
from app.schemas.request import DeliveryRequest
|
||||
from app.schemas.request import DeliveryCalculationRequest
|
||||
from app.schemas.response import DeliveryPrice
|
||||
|
||||
|
||||
@@ -76,7 +76,9 @@ class AggregatorService:
|
||||
self._provider_price_multiplier = provider_price_multiplier
|
||||
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
||||
|
||||
async def get_all_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]:
|
||||
async def get_all_prices(
|
||||
self, request: DeliveryCalculationRequest
|
||||
) -> list[DeliveryPrice]:
|
||||
normalized_request = normalize_delivery_request(
|
||||
request, weight_round_scale=self._weight_round_scale
|
||||
)
|
||||
@@ -149,7 +151,7 @@ class AggregatorService:
|
||||
self,
|
||||
*,
|
||||
provider: DeliveryProvider,
|
||||
request: DeliveryRequest,
|
||||
request: DeliveryCalculationRequest,
|
||||
cache_key: str,
|
||||
) -> list[DeliveryPrice]:
|
||||
cached_prices = await self._get_cached_prices(cache_key)
|
||||
@@ -193,12 +195,13 @@ class AggregatorService:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _to_provider_request(request: NormalizedDeliveryRequest) -> DeliveryRequest:
|
||||
return DeliveryRequest(
|
||||
def _to_provider_request(
|
||||
request: NormalizedDeliveryRequest,
|
||||
) -> DeliveryCalculationRequest:
|
||||
return DeliveryCalculationRequest(
|
||||
entity=request.entity,
|
||||
from_city=request.from_city,
|
||||
to_city=request.to_city,
|
||||
country_code=request.country_code,
|
||||
weight_kg=request.weight_kg,
|
||||
length_cm=request.length_cm,
|
||||
width_cm=request.width_cm,
|
||||
@@ -212,7 +215,6 @@ class AggregatorService:
|
||||
"entity": request.entity,
|
||||
"from_city": request.from_city,
|
||||
"to_city": request.to_city,
|
||||
"country_code": request.country_code,
|
||||
"weight_kg": str(request.weight_kg),
|
||||
"length_cm": str(request.length_cm),
|
||||
"width_cm": str(request.width_cm),
|
||||
|
||||
Reference in New Issue
Block a user