020 update city code resolving

This commit is contained in:
Раис Юсупалиев
2026-03-21 10:03:46 +03:00
parent c4175121a0
commit 163f379a65
14 changed files with 45939 additions and 271 deletions
+4 -2
View File
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from app.schemas.request import DeliveryRequest from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
@@ -10,7 +10,9 @@ class DeliveryProvider(ABC):
name: str name: str
@abstractmethod @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.""" """Fetch provider tariffs for a delivery request."""
raise NotImplementedError raise NotImplementedError
+34 -80
View File
@@ -15,9 +15,10 @@ from app.adapters.delivery_providers.cdek.order_mapper import (
map_cdek_order_request, map_cdek_order_request,
map_cdek_order_response, map_cdek_order_response,
) )
from app.cities import cities_map
from app.config import AdapterConfig from app.config import AdapterConfig
from app.schemas.order import OrderCreateRequest, OrderCreateResponse 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.schemas.response import DeliveryPrice
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -45,16 +46,16 @@ class CDEKClient:
self._http_client = http_client self._http_client = http_client
self._auth_client = auth_client self._auth_client = auth_client
normalized_base_url = base_url.rstrip("/") 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._tariff_url = f"{normalized_base_url}/calculator/tarifflist"
self._orders_url = f"{normalized_base_url}/orders" self._orders_url = f"{normalized_base_url}/orders"
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
self._retry_attempts = retry_attempts self._retry_attempts = retry_attempts
self._retry_backoff_seconds = retry_backoff_seconds self._retry_backoff_seconds = retry_backoff_seconds
self._sleep = sleep 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) payload = await self._build_payload(request)
for attempt in range(self._retry_attempts + 1): for attempt in range(self._retry_attempts + 1):
try: try:
@@ -89,7 +90,6 @@ class CDEKClient:
if not isinstance(raw_payload, dict): if not isinstance(raw_payload, dict):
raise CDEKClientError("CDEK tariff payload must be a JSON object.") raise CDEKClientError("CDEK tariff payload must be a JSON object.")
log.info(f"Founded {len(response.json())} tarrifs")
return raw_payload return raw_payload
raise CDEKClientError("CDEK tariff request failed unexpectedly.") raise CDEKClientError("CDEK tariff request failed unexpectedly.")
@@ -157,15 +157,11 @@ class CDEKClient:
def _should_retry(status_code: int) -> bool: def _should_retry(status_code: int) -> bool:
return status_code == 429 or status_code >= 500 return status_code == 429 or status_code >= 500
async def _build_payload(self, request: DeliveryRequest) -> dict[str, Any]: async def _build_payload(
from_city_code = await self._resolve_city_code( self, request: DeliveryCalculationRequest
request.from_city, ) -> dict[str, Any]:
country_code=request.country_code, from_city_code = self._get_cdek_city_code(request.from_city)
) to_city_code = self._get_cdek_city_code(request.to_city)
to_city_code = await self._resolve_city_code(
request.to_city,
country_code=request.country_code,
)
return { return {
"type": 2, "type": 2,
"from_location": {"code": from_city_code}, "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: @staticmethod
normalized_city = city.strip().casefold() def _get_cdek_city_code(city_id: int) -> int:
normalized_country_code = ( city_entry = cities_map.get(str(city_id))
country_code.strip().upper() if country_code is not None else "" if not isinstance(city_entry, dict):
)
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):
raise CDEKRequestError( 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: try:
city_code = int(raw_city_code) return int(raw_city_code)
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise CDEKClientError("CDEK city lookup response city code is invalid.") from exc raise CDEKRequestError(
f"CDEK city code is invalid for city id {city_id}."
self._city_code_cache[cache_key] = city_code ) from exc
return city_code
class CDEKProvider(DeliveryProvider): class CDEKProvider(DeliveryProvider):
@@ -283,7 +235,9 @@ class CDEKProvider(DeliveryProvider):
) )
return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds) 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) raw_payload = await self._client.get_raw_price(request)
return map_cdek_response(raw_payload) return map_cdek_response(raw_payload)
+45659
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -7,7 +7,7 @@ from app.config import Settings
from app.controllers.http_client import build_controller_http_client from app.controllers.http_client import build_controller_http_client
from app.repositories.cache.redis_cache import PriceCache from app.repositories.cache.redis_cache import PriceCache
from app.schemas.order import OrderCreateRequest, OrderCreateResponse 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.schemas.response import DeliveryPrice
from app.services.aggregator import ( from app.services.aggregator import (
AggregatorService, AggregatorService,
@@ -58,7 +58,7 @@ async def get_aggregator_service(request: Request) -> AggregatorService:
response_model=list[DeliveryPrice], response_model=list[DeliveryPrice],
) )
async def get_delivery_price( async def get_delivery_price(
delivery_request: DeliveryRequest, delivery_request: DeliveryCalculationRequest,
service: AggregatorService = Depends(get_aggregator_service), service: AggregatorService = Depends(get_aggregator_service),
) -> list[DeliveryPrice]: ) -> list[DeliveryPrice]:
try: try:
+9 -14
View File
@@ -21,7 +21,6 @@ class RequestLike(Protocol):
entity: object entity: object
from_city: object from_city: object
to_city: object to_city: object
country_code: object
weight_kg: object weight_kg: object
length_cm: object length_cm: object
width_cm: object width_cm: object
@@ -31,9 +30,8 @@ class RequestLike(Protocol):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class NormalizedDeliveryRequest: class NormalizedDeliveryRequest:
entity: str entity: str
from_city: str from_city: int
to_city: str to_city: int
country_code: str | None
weight_kg: Decimal weight_kg: Decimal
length_cm: Decimal length_cm: Decimal
width_cm: Decimal width_cm: Decimal
@@ -61,9 +59,8 @@ def normalize_delivery_request(
return NormalizedDeliveryRequest( return NormalizedDeliveryRequest(
entity=str(_get_required_attr(request, "entity")), entity=str(_get_required_attr(request, "entity")),
from_city=_normalize_text(_get_required_attr(request, "from_city")), from_city=_normalize_city_id(_get_required_attr(request, "from_city")),
to_city=_normalize_text(_get_required_attr(request, "to_city")), to_city=_normalize_city_id(_get_required_attr(request, "to_city")),
country_code=_normalize_country_code(_get_optional_attr(request, "country_code")),
weight_kg=_normalize_weight( weight_kg=_normalize_weight(
_to_decimal(_get_required_attr(request, "weight_kg")), _to_decimal(_get_required_attr(request, "weight_kg")),
normalized_weight_scale, normalized_weight_scale,
@@ -219,13 +216,11 @@ def _normalize_text(value: object) -> str:
return str(value).strip() return str(value).strip()
def _normalize_country_code(value: object) -> str | None: def _normalize_city_id(value: object) -> int:
if value is _MISSING or value is None: city_id = _try_to_int(value)
return None if city_id is None:
normalized = str(value).strip().upper() raise ValueError(f"City identifier must be an integer: {value!r}")
if not normalized: return city_id
return None
return normalized
def _clamp_scale(scale: int) -> int: def _clamp_scale(scale: int) -> int:
+4 -5
View File
@@ -2,7 +2,7 @@
from enum import StrEnum from enum import StrEnum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, StrictInt
class DeliveryEntity(StrEnum): class DeliveryEntity(StrEnum):
@@ -15,11 +15,10 @@ class ParcelType(StrEnum):
PARCEL = "parcel" PARCEL = "parcel"
class DeliveryRequest(BaseModel): class DeliveryCalculationRequest(BaseModel):
entity: DeliveryEntity entity: DeliveryEntity
from_city: str = Field(min_length=1) from_city: StrictInt
to_city: str = Field(min_length=1) to_city: StrictInt
country_code: str | None = None
weight_kg: float = Field(gt=0) weight_kg: float = Field(gt=0)
length_cm: float = Field(gt=0) length_cm: float = Field(gt=0)
width_cm: float = Field(gt=0) width_cm: float = Field(gt=0)
+9 -7
View File
@@ -16,7 +16,7 @@ from app.domain.price import (
normalize_delivery_request, normalize_delivery_request,
) )
from app.schemas.order import OrderCreateRequest, OrderCreateResponse 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.schemas.response import DeliveryPrice
@@ -76,7 +76,9 @@ class AggregatorService:
self._provider_price_multiplier = provider_price_multiplier self._provider_price_multiplier = provider_price_multiplier
self._filter_and_sort_prices = filter_and_sort_prices_fn 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( normalized_request = normalize_delivery_request(
request, weight_round_scale=self._weight_round_scale request, weight_round_scale=self._weight_round_scale
) )
@@ -149,7 +151,7 @@ class AggregatorService:
self, self,
*, *,
provider: DeliveryProvider, provider: DeliveryProvider,
request: DeliveryRequest, request: DeliveryCalculationRequest,
cache_key: str, cache_key: str,
) -> list[DeliveryPrice]: ) -> list[DeliveryPrice]:
cached_prices = await self._get_cached_prices(cache_key) cached_prices = await self._get_cached_prices(cache_key)
@@ -193,12 +195,13 @@ class AggregatorService:
return return
@staticmethod @staticmethod
def _to_provider_request(request: NormalizedDeliveryRequest) -> DeliveryRequest: def _to_provider_request(
return DeliveryRequest( request: NormalizedDeliveryRequest,
) -> DeliveryCalculationRequest:
return DeliveryCalculationRequest(
entity=request.entity, entity=request.entity,
from_city=request.from_city, from_city=request.from_city,
to_city=request.to_city, to_city=request.to_city,
country_code=request.country_code,
weight_kg=request.weight_kg, weight_kg=request.weight_kg,
length_cm=request.length_cm, length_cm=request.length_cm,
width_cm=request.width_cm, width_cm=request.width_cm,
@@ -212,7 +215,6 @@ class AggregatorService:
"entity": request.entity, "entity": request.entity,
"from_city": request.from_city, "from_city": request.from_city,
"to_city": request.to_city, "to_city": request.to_city,
"country_code": request.country_code,
"weight_kg": str(request.weight_kg), "weight_kg": str(request.weight_kg),
"length_cm": str(request.length_cm), "length_cm": str(request.length_cm),
"width_cm": str(request.width_cm), "width_cm": str(request.width_cm),
+6 -5
View File
@@ -1,7 +1,7 @@
# Spec Tasks Index # Spec Tasks Index
> ⚠️ This file is generated. Do not edit manually. > ⚠️ This file is generated. Do not edit manually.
> Generated at (UTC): `2026-03-18T18:41:29+00:00` > Generated at (UTC): `2026-03-21T07:05:32+00:00`
## Tasks ## Tasks
@@ -25,11 +25,12 @@
| 015 | DONE | 2026-03-13 | Add minimal OpenTelemetry tracing | `spec/tasks/015_add_minimal_opentelemetry_tracing.md` | | 015 | DONE | 2026-03-13 | Add minimal OpenTelemetry tracing | `spec/tasks/015_add_minimal_opentelemetry_tracing.md` |
| 016 | DONE | 2026-03-14 | Add CDEK order registration adapter | `spec/tasks/016_add_cdek_order_registration_adapter.md` | | 016 | DONE | 2026-03-14 | Add CDEK order registration adapter | `spec/tasks/016_add_cdek_order_registration_adapter.md` |
| 017 | DONE | 2026-03-14 | Return all tariffs from CDEK price calculation | `spec/tasks/017_return_all_cdek_tariffs.md` | | 017 | DONE | 2026-03-14 | Return all tariffs from CDEK price calculation | `spec/tasks/017_return_all_cdek_tariffs.md` |
| 018 | TODO | 2026-03-16 | Add optional parcel type filter to price request | `spec/tasks/018_add_optional_parcel_type_filter_to_price_request.md` | | 018 | DONE | 2026-03-16 | Add optional parcel type filter to price request | `spec/tasks/018_add_optional_parcel_type_filter_to_price_request.md` |
| 019 | TODO | 2026-03-14 | Add CDEK order creation endpoint | `spec/tasks/019_add_cdek_order_creation_endpoint.md` | | 019 | TODO | 2026-03-14 | Add CDEK order creation endpoint | `spec/tasks/019_add_cdek_order_creation_endpoint.md` |
| 020 | DONE | 2026-03-21 | Rename price request model and use cities_map for CDEK codes | `spec/tasks/020_rename_price_request_model_and_use_cities_map_for_cdek_codes.md` |
## Summary ## Summary
- Total: **20** - Total: **21**
- TODO: **2** - TODO: **1**
- DONE: **18** - DONE: **20**
+18 -8
View File
@@ -15,7 +15,7 @@
## Продуктовые требования ## Продуктовые требования
- Принимать запрос на расчёт стоимости доставки (откуда, куда, вес, габариты) - Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты)
- Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления - Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления
- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери" - Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери"
- Опрашивать всех зарегистрированных провайдеров параллельно - Опрашивать всех зарегистрированных провайдеров параллельно
@@ -23,6 +23,7 @@
- Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком - Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком
- Кешировать ответы провайдеров для исключения повторных внешних запросов. Время кэширования вынести в конфиг - Кешировать ответы провайдеров для исключения повторных внешних запросов. Время кэширования вынести в конфиг
- Применять к цене, полученной от провайдера, конфигурируемый мультипликатор и округлять итоговую цену до целого значения - Применять к цене, полученной от провайдера, конфигурируемый мультипликатор и округлять итоговую цену до целого значения
- Для расчёта стоимости использовать локальный справочник `cities_map` с provider-specific данными города; для CDEK брать `cdek.code` без обращения к API подсказок городов
--- ---
@@ -42,14 +43,14 @@
## Компоненты ## Компоненты
### Controller (`app/controllers/v1/delivery.py`) ### Controller (`app/controllers/v1/delivery.py`)
- `POST /api/v1/delivery/price` — принимает `DeliveryRequest`, возвращает `list[DeliveryPrice]` - `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
- `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse` - `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse`
- Парсинг и валидация входных данных через Pydantic - Парсинг и валидация входных данных через Pydantic
- Маппинг исключений сервиса в HTTP-ответы - Маппинг исключений сервиса в HTTP-ответы
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()` или `AggregatorService.create_order()` - Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()` или `AggregatorService.create_order()`
### Service (`app/services/aggregator.py`) ### Service (`app/services/aggregator.py`)
- `AggregatorService.get_all_prices(request: DeliveryRequest) -> list[DeliveryPrice]` - `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]`
- Распределяет запросы по всем зарегистрированным провайдерам через `asyncio.gather(..., return_exceptions=True)` - Распределяет запросы по всем зарегистрированным провайдерам через `asyncio.gather(..., return_exceptions=True)`
- Принимает от каждого провайдера список тарифов и объединяет их в единый список - Принимает от каждого провайдера список тарифов и объединяет их в единый список
- Фильтрует упавшие результаты - Фильтрует упавшие результаты
@@ -64,7 +65,7 @@
- Логика сравнения цен - Логика сравнения цен
- Правила применения конфигурируемого мультипликатора к ценам провайдеров - Правила применения конфигурируемого мультипликатора к ценам провайдеров
- Округление цены после применения мультипликатора до целого значения по детерминированному правилу - Округление цены после применения мультипликатора до целого значения по детерминированному правилу
- Нормализация входных данных (например, правила округления веса) - Нормализация входных данных (например, идентификаторов городов и правил округления веса)
- Чистые функции, без IO, без зависимостей от фреймворков - Чистые функции, без IO, без зависимостей от фреймворков
### Repository (`app/repositories/cache/`) ### Repository (`app/repositories/cache/`)
@@ -77,25 +78,34 @@
```python ```python
class DeliveryProvider(ABC): class DeliveryProvider(ABC):
name: str name: str
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]: ... async def get_prices(self, request: DeliveryCalculationRequest) -> list[DeliveryPrice]: ...
``` ```
- `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с) - `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с)
- `cdek/auth.py` — управление OAuth2-токеном - `cdek/auth.py` — управление OAuth2-токеном
- `cdek/mapper.py` — ответ CDEK → `list[DeliveryPrice]` - `cdek/mapper.py` — ответ CDEK → `list[DeliveryPrice]`
- `cdek/order_mapper.py` — request/response mapping для регистрации заказа CDEK - `cdek/order_mapper.py` — request/response mapping для регистрации заказа CDEK
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case - Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
- Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API
Для сценария создания заказа CDEK adapter принимает валидированную order model, отправляет контракт `Регистрация заказа (тип "доставка", до двери)` из `http-client.http` и возвращает внутреннюю response model без утечки HTTP-деталей в Service. Для сценария создания заказа CDEK adapter принимает валидированную order model, отправляет контракт `Регистрация заказа (тип "доставка", до двери)` из `http-client.http` и возвращает внутреннюю response model без утечки HTTP-деталей в Service.
--- ---
## Reference Data
- `cities_map` содержит city identifier и provider-specific данные города для price flow.
- Справочник может быть частично заполнен; отсутствие provider-specific данных для города должно обрабатываться детерминированно.
- Структура справочника должна допускать появление дополнительных провайдеров без изменения публичного API расчёта.
---
## Модели данных ## Модели данных
### Входная: `DeliveryRequest` ### Входная: `DeliveryCalculationRequest`
``` ```
entity: Enum(individual, legal) entity: Enum(individual, legal)
from_city: str from_city: int
to_city: str to_city: int
weight_kg: float weight_kg: float
length_cm: float length_cm: float
width_cm: float width_cm: float
@@ -0,0 +1,58 @@
---
id: 020
title: Rename price request model and use cities_map for CDEK codes
status: DONE
created: 2026-03-21
---
## Context
Текущий price flow принимает `DeliveryRequest` со строковыми `from_city` и `to_city`, а также использует внешний lookup города через `_resolve_city_code()` в CDEK client. Новый контракт API переходит на идентификаторы городов и локальный справочник `cities_map`, который хранит provider-specific данные города. Для этого изменения нужен только минимальный набор новых валидаций, необходимый для корректного price flow.
## Goal
Переименовать request model расчёта доставки в `DeliveryCalculationRequest`, перевести поля `from_city` и `to_city` на тип `int`, убрать `country_code` из price API и использовать `cities_map` как источник provider city codes для CDEK tariff request вместо `_resolve_city_code()`, добавив только минимально необходимые валидации для этого перехода.
## Constraints
- Scope задачи ограничен price calculation flow: request schema, controller/service wiring, domain normalization, cache key composition, provider contract и CDEK adapter/client.
- `POST /api/v1/delivery/order`, `OrderCreateRequest`, `OrderCreateResponse` и `country_code` внутри order payload не изменять.
- Controller не добавляет business rules; Service не реализует provider-specific lookup; pure validation и normalization остаются в Business Logic.
- Для расчёта CDEK использовать только локальный `cities_map`; внешний city lookup через CDEK API и `_resolve_city_code()` должен быть удалён из client.
- Не требовать полноты `cities_map`; отсутствие записи города или provider-specific данных должно обрабатываться детерминированно.
- Учитывать provider-specific структуру `cities_map`, но не реализовывать новых providers в рамках этой задачи.
- Новые проверки ограничить минимально необходимыми для работы контракта и CDEK lookup: тип city identifier во входной schema и наличие валидного `cdek.code` для используемого города.
- Не добавлять предварительную валидацию всего `cities_map`, проверку неиспользуемых provider sections, проверку `city_uuid`, `full_name`, `label`, `country` или иные дополнительные consistency-checks.
- Не добавлять новые range/business validations для city identifier сверх тех, что нужны для поиска города в `cities_map`.
- Не изменять файлы в `spec/`, кроме этой задачи, `spec/overview.md` и сгенерированного `spec/index.md`.
## Acceptance criteria
- Модель запроса расчёта переименована в `DeliveryCalculationRequest`; controller, service и provider contract для price flow используют новое имя.
- Поля `from_city` и `to_city` в price request принимают целочисленные идентификаторы города; поле `country_code` отсутствует в публичном API расчёта доставки.
- Domain normalization и service cache key больше не используют `country_code` и корректно работают с city identifiers.
- Новые валидации, добавленные этой задачей, ограничены:
- schema-level проверкой, что `from_city` и `to_city` передаются как `int`
- runtime-проверкой, что для конкретного city identifier, использованного в CDEK price flow, существует валидный `cdek.code`
- CDEK client строит payload `from_location.code` и `to_location.code` на основе `cities_map` и значений `cdek.code` для переданных city identifiers.
- Если city identifier отсутствует в `cities_map` или для него нет валидного `cdek.code`, CDEK price flow завершается детерминированной provider request error без внешнего city lookup.
- Метод `_resolve_city_code()` и связанный HTTP lookup `location/suggest/cities` удалены из CDEK client; price flow не обращается к CDEK API подсказок городов.
- Existing order creation flow и order schemas остаются без изменения.
## Definition of Done
- [ ] Обновлены request schema, controller/service/provider type hints и связанные импорты для нового имени `DeliveryCalculationRequest`.
- [ ] Удалён `country_code` из price calculation contract, normalization и cache key.
- [ ] Реализован lookup CDEK city codes через `cities_map` без внешнего city suggest API.
- [ ] Удалён `_resolve_city_code()` и обновлены adapter tests под новое поведение.
- [ ] Добавлены или обновлены tests только для минимально необходимой schema validation, service wiring, cache key и CDEK payload mapping/error scenarios.
## Tests
- Обновить `tests/domain/test_price.py` только для проверки, что normalizer корректно принимает и сохраняет city identifiers без `country_code`.
- Обновить `tests/services/test_aggregator.py` для проверки нового request model, отсутствия `country_code` в service flow и обновлённого cache key.
- Обновить `tests/controllers/v1/test_delivery.py` только для проверки API schema с `from_city`/`to_city` типа `int` и 422 на невалидные значения типа.
- Обновить `tests/adapters/delivery_providers/cdek/test_client.py` для проверки построения payload по `cities_map`, ошибок при отсутствии `cdek.code` или city entry и отсутствия city lookup request.
- При необходимости обновить `tests/smoke/test_app_import.py` только для совместимости нового request model с app wiring.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_client.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -11,9 +11,10 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider, CDEKProvider,
CDEKRequestError, CDEKRequestError,
) )
from app.cities import cities_map
from app import config as config_module from app import config as config_module
from app.config import AdapterConfig, Settings from app.config import AdapterConfig, Settings
from app.schemas.request import DeliveryEntity, DeliveryRequest from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
class StubAuthClient: class StubAuthClient:
@@ -62,28 +63,14 @@ class SequenceHTTPClient:
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
timeout: float | None = None, timeout: float | None = None,
) -> httpx.Response: ) -> httpx.Response:
self.calls.append( raise AssertionError("CDEK city lookup API must not be called.")
{
"method": "GET",
"url": url,
"json": None,
"data": None,
"params": params,
"headers": headers,
"timeout": timeout,
}
)
result = self._next_result()
if isinstance(result, Exception):
raise result
return result
class RecordingHTTPClient: class RecordingHTTPClient:
def __init__(self) -> None: def __init__(self) -> None:
self.auth_timeouts: list[float | None] = [] self.auth_timeouts: list[float | None] = []
self.city_lookup_timeouts: list[float | None] = []
self.tariff_timeouts: list[float | None] = [] self.tariff_timeouts: list[float | None] = []
self.tariff_payloads: list[dict[str, Any]] = []
async def get( async def get(
self, self,
@@ -93,16 +80,7 @@ class RecordingHTTPClient:
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
timeout: float | None = None, timeout: float | None = None,
) -> httpx.Response: ) -> httpx.Response:
request = httpx.Request("GET", url, params=params) raise AssertionError("CDEK city lookup API must not be called.")
self.city_lookup_timeouts.append(timeout)
assert headers == {"Authorization": "Bearer provider-token"}
city = params.get("name") if params else None
code_by_city = {"Moscow": 44, "Kazan": 424}
return httpx.Response(
200,
json={"code": code_by_city.get(city, 44), "full_name": city},
request=request,
)
async def post( async def post(
self, self,
@@ -125,6 +103,7 @@ class RecordingHTTPClient:
assert headers == {"Authorization": "Bearer provider-token"} assert headers == {"Authorization": "Bearer provider-token"}
assert data is None assert data is None
assert json is not None assert json is not None
self.tariff_payloads.append(json)
return httpx.Response( return httpx.Response(
200, 200,
json={ json={
@@ -149,33 +128,29 @@ class RecordingHTTPClient:
) )
def _make_request(**overrides: object) -> DeliveryRequest: def _make_request(**overrides: object) -> DeliveryCalculationRequest:
payload: dict[str, object] = { payload: dict[str, object] = {
"entity": DeliveryEntity.INDIVIDUAL, "entity": DeliveryEntity.INDIVIDUAL,
"from_city": "Moscow", "from_city": 1,
"to_city": "Kazan", "to_city": 2,
"country_code": None,
"weight_kg": 1.2, "weight_kg": 1.2,
"length_cm": 20, "length_cm": 20,
"width_cm": 10, "width_cm": 10,
"height_cm": 5, "height_cm": 5,
} }
payload.update(overrides) payload.update(overrides)
return DeliveryRequest(**payload) return DeliveryCalculationRequest(**payload)
def _cdek_code(city_id: str) -> int:
city_entry = cities_map[city_id]
cdek_data = city_entry["cdek"]
assert isinstance(cdek_data, dict)
return int(cdek_data["code"])
def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None: def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None:
request = _make_request() request = _make_request()
city_lookup_moscow = httpx.Response(
200,
json={"code": 44, "full_name": "Moscow"},
request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"),
)
city_lookup_kazan = httpx.Response(
200,
json={"code": 424, "full_name": "Kazan"},
request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"),
)
first_error = httpx.ReadTimeout( first_error = httpx.ReadTimeout(
"read timeout", "read timeout",
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"), request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
@@ -185,9 +160,7 @@ def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None:
json={"tariff_codes": [{"delivery_sum": 500}]}, json={"tariff_codes": [{"delivery_sum": 500}]},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"), request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
) )
http_client = SequenceHTTPClient( http_client = SequenceHTTPClient([first_error, second_response])
[city_lookup_moscow, city_lookup_kazan, first_error, second_response]
)
sleep_calls: list[float] = [] sleep_calls: list[float] = []
async def fake_sleep(seconds: float) -> None: async def fake_sleep(seconds: float) -> None:
@@ -206,37 +179,22 @@ def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None:
result = asyncio.run(client.get_raw_price(request)) result = asyncio.run(client.get_raw_price(request))
assert result == {"tariff_codes": [{"delivery_sum": 500}]} assert result == {"tariff_codes": [{"delivery_sum": 500}]}
assert len(http_client.calls) == 4 assert len(http_client.calls) == 2
assert http_client.calls[0]["method"] == "GET" assert http_client.calls[0]["method"] == "POST"
assert http_client.calls[0]["params"] == { assert http_client.calls[0]["json"]["from_location"] == {"code": _cdek_code("1")}
"name": "Moscow", assert http_client.calls[0]["json"]["to_location"] == {"code": _cdek_code("2")}
} assert http_client.calls[0]["timeout"] == 10.0
assert http_client.calls[1]["method"] == "GET"
assert http_client.calls[2]["method"] == "POST"
assert http_client.calls[2]["json"]["from_location"] == {"code": 44}
assert http_client.calls[2]["json"]["to_location"] == {"code": 424}
assert http_client.calls[2]["timeout"] == 10.0
assert sleep_calls == [0.5] assert sleep_calls == [0.5]
def test_cdek_client_passes_country_code_when_provided() -> None: def test_cdek_client_builds_payload_from_cities_map_codes() -> None:
request = _make_request(country_code="KZ") request = _make_request()
city_lookup_from = httpx.Response(
200,
json={"code": 88, "full_name": "Moscow"},
request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"),
)
city_lookup_to = httpx.Response(
200,
json={"code": 99, "full_name": "Kazan"},
request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"),
)
tariff_response = httpx.Response( tariff_response = httpx.Response(
200, 200,
json={"tariff_codes": [{"delivery_sum": 500}]}, json={"tariff_codes": [{"delivery_sum": 500}]},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"), request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
) )
http_client = SequenceHTTPClient([city_lookup_from, city_lookup_to, tariff_response]) http_client = SequenceHTTPClient([tariff_response])
client = CDEKClient( client = CDEKClient(
http_client=http_client, # type: ignore[arg-type] http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type] auth_client=StubAuthClient(), # type: ignore[arg-type]
@@ -246,30 +204,54 @@ def test_cdek_client_passes_country_code_when_provided() -> None:
asyncio.run(client.get_raw_price(request)) asyncio.run(client.get_raw_price(request))
assert http_client.calls[0]["params"] == { assert http_client.calls[0]["json"]["from_location"] == {"code": _cdek_code("1")}
"name": "Moscow", assert http_client.calls[0]["json"]["to_location"] == {"code": _cdek_code("2")}
"country_code": "KZ", assert http_client.calls[0]["json"]["packages"] == [
} {"weight": 1200, "length": 20, "width": 10, "height": 5}
]
assert http_client.calls[0]["params"] is None
def test_cdek_client_raises_when_city_mapping_is_missing() -> None:
request = _make_request(from_city=999999)
http_client = SequenceHTTPClient([])
client = CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=0,
)
with pytest.raises(CDEKRequestError, match="city id 999999"):
asyncio.run(client.get_raw_price(request))
assert http_client.calls == []
def test_cdek_client_raises_when_cdek_code_is_not_configured() -> None:
request = _make_request(to_city=3)
http_client = SequenceHTTPClient([])
client = CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=0,
)
with pytest.raises(CDEKRequestError, match="city id 3"):
asyncio.run(client.get_raw_price(request))
assert http_client.calls == []
def test_cdek_client_raises_on_non_retriable_http_error() -> None: def test_cdek_client_raises_on_non_retriable_http_error() -> None:
request = _make_request() request = _make_request()
city_lookup_moscow = httpx.Response(
200,
json={"code": 44, "full_name": "Moscow"},
request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"),
)
city_lookup_kazan = httpx.Response(
200,
json={"code": 424, "full_name": "Kazan"},
request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"),
)
bad_response = httpx.Response( bad_response = httpx.Response(
400, 400,
json={"error": "bad request"}, json={"error": "bad request"},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"), request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
) )
http_client = SequenceHTTPClient([city_lookup_moscow, city_lookup_kazan, bad_response]) http_client = SequenceHTTPClient([bad_response])
client = CDEKClient( client = CDEKClient(
http_client=http_client, # type: ignore[arg-type] http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type] auth_client=StubAuthClient(), # type: ignore[arg-type]
@@ -280,26 +262,7 @@ def test_cdek_client_raises_on_non_retriable_http_error() -> None:
with pytest.raises(CDEKClientError): with pytest.raises(CDEKClientError):
asyncio.run(client.get_raw_price(request)) asyncio.run(client.get_raw_price(request))
assert len(http_client.calls) == 3 assert len(http_client.calls) == 1
def test_cdek_client_raises_when_city_code_not_found() -> None:
request = _make_request()
city_lookup_empty = httpx.Response(
200,
json=[],
request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"),
)
http_client = SequenceHTTPClient([city_lookup_empty])
client = CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=0,
)
with pytest.raises(CDEKRequestError, match="no matches"):
asyncio.run(client.get_raw_price(request))
def test_provider_uses_adapter_yaml_config_for_timeout_and_cache_ttl( def test_provider_uses_adapter_yaml_config_for_timeout_and_cache_ttl(
@@ -353,8 +316,17 @@ observability:
assert provider.cache_ttl_seconds == 777 assert provider.cache_ttl_seconds == 777
assert http_client.auth_timeouts == [7.5] assert http_client.auth_timeouts == [7.5]
assert http_client.city_lookup_timeouts == [7.5, 7.5]
assert http_client.tariff_timeouts == [7.5] assert http_client.tariff_timeouts == [7.5]
assert http_client.tariff_payloads == [
{
"type": 2,
"from_location": {"code": _cdek_code("1")},
"to_location": {"code": _cdek_code("2")},
"packages": [
{"weight": 1200, "length": 20, "width": 10, "height": 5}
],
}
]
assert [price.provider for price in result] == ["cdek", "cdek"] assert [price.provider for price in result] == ["cdek", "cdek"]
assert [price.service_name for price in result] == ["Express", "Economy"] assert [price.service_name for price in result] == ["Express", "Economy"]
assert [price.price for price in result] == [Decimal("899.00"), Decimal("499.00")] assert [price.price for price in result] == [Decimal("899.00"), Decimal("499.00")]
@@ -375,6 +347,5 @@ def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
assert provider.cache_ttl_seconds == 900 assert provider.cache_ttl_seconds == 900
assert http_client.auth_timeouts == [10.0] assert http_client.auth_timeouts == [10.0]
assert http_client.city_lookup_timeouts == [10.0, 10.0]
assert http_client.tariff_timeouts == [10.0] assert http_client.tariff_timeouts == [10.0]
assert len(result) == 2 assert len(result) == 2
+25 -18
View File
@@ -7,7 +7,7 @@ import pytest
from app.controllers.v1 import delivery as delivery_controller from app.controllers.v1 import delivery as delivery_controller
from app.controllers.v1.delivery import get_aggregator_service from app.controllers.v1.delivery import get_aggregator_service
from app.main import create_app from app.main import create_app
from app.schemas.request import DeliveryEntity, DeliveryRequest, ParcelType from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity, ParcelType
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
from app.services.aggregator import ( from app.services.aggregator import (
AggregatorService, AggregatorService,
@@ -20,9 +20,9 @@ class StubAggregatorService:
def __init__(self, *, response: object, error: Exception | None = None) -> None: def __init__(self, *, response: object, error: Exception | None = None) -> None:
self._response = response self._response = response
self._error = error self._error = error
self.calls: list[DeliveryRequest] = [] self.calls: list[DeliveryCalculationRequest] = []
async def get_all_prices(self, request: DeliveryRequest) -> object: async def get_all_prices(self, request: DeliveryCalculationRequest) -> object:
self.calls.append(request) self.calls.append(request)
if self._error is not None: if self._error is not None:
raise self._error raise self._error
@@ -34,9 +34,11 @@ class StubPriceProvider:
self.name = "stub-provider" self.name = "stub-provider"
self.cache_ttl_seconds = 900 self.cache_ttl_seconds = 900
self._response = response self._response = response
self.calls: list[DeliveryRequest] = [] self.calls: list[DeliveryCalculationRequest] = []
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]: async def get_prices(
self, request: DeliveryCalculationRequest
) -> list[DeliveryPrice]:
self.calls.append(request) self.calls.append(request)
return self._response return self._response
@@ -51,8 +53,8 @@ def _install_service_override(app, service: StubAggregatorService) -> None:
def _valid_payload() -> dict[str, object]: def _valid_payload() -> dict[str, object]:
return { return {
"entity": "individual", "entity": "individual",
"from_city": "Moscow", "from_city": 1,
"to_city": "Kazan", "to_city": 2,
"weight_kg": 2.5, "weight_kg": 2.5,
"length_cm": 30.0, "length_cm": 30.0,
"width_cm": 20.0, "width_cm": 20.0,
@@ -91,9 +93,11 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
cache_ttl_seconds = 900 cache_ttl_seconds = 900
def __init__(self) -> None: def __init__(self) -> None:
self.calls: list[DeliveryRequest] = [] self.calls: list[DeliveryCalculationRequest] = []
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]: async def get_prices(
self, request: DeliveryCalculationRequest
) -> list[DeliveryPrice]:
self.calls.append(request) self.calls.append(request)
return [ return [
DeliveryPrice( DeliveryPrice(
@@ -230,11 +234,10 @@ def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == [price.model_dump(mode="json") for price in expected_prices] assert response.json() == [price.model_dump(mode="json") for price in expected_prices]
assert len(service.calls) == 1 assert len(service.calls) == 1
assert service.calls[0] == DeliveryRequest( assert service.calls[0] == DeliveryCalculationRequest(
entity=DeliveryEntity.INDIVIDUAL, entity=DeliveryEntity.INDIVIDUAL,
from_city="Moscow", from_city=1,
to_city="Kazan", to_city=2,
country_code=None,
weight_kg=2.5, weight_kg=2.5,
length_cm=30.0, length_cm=30.0,
width_cm=20.0, width_cm=20.0,
@@ -243,12 +246,16 @@ def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None:
) )
def test_post_delivery_price_accepts_optional_country_code() -> None: @pytest.mark.parametrize(("field_name", "field_value"), [("from_city", "1"), ("to_city", "2")])
def test_post_delivery_price_rejects_non_integer_city_identifier(
field_name: str,
field_value: str,
) -> None:
service = StubAggregatorService(response=[]) service = StubAggregatorService(response=[])
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
payload = _valid_payload() payload = _valid_payload()
payload["country_code"] = "kz" payload[field_name] = field_value
async def run_request() -> httpx.Response: async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
@@ -260,9 +267,9 @@ def test_post_delivery_price_accepts_optional_country_code() -> None:
response = asyncio.run(run_request()) response = asyncio.run(run_request())
assert response.status_code == 200 assert response.status_code == 422
assert len(service.calls) == 1 assert response.json()["detail"][0]["loc"] == ["body", field_name]
assert service.calls[0].country_code == "kz" assert service.calls == []
def test_post_delivery_price_accepts_optional_parcel_type() -> None: def test_post_delivery_price_accepts_optional_parcel_type() -> None:
+9 -13
View File
@@ -7,7 +7,7 @@ from app.domain.price import (
normalize_delivery_request, normalize_delivery_request,
sort_prices_by_price, sort_prices_by_price,
) )
from app.schemas.request import DeliveryEntity, DeliveryRequest, ParcelType from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity, ParcelType
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
@@ -24,19 +24,18 @@ def _make_price(**overrides) -> DeliveryPrice:
return DeliveryPrice.model_construct(**payload) return DeliveryPrice.model_construct(**payload)
def _make_request(**overrides) -> DeliveryRequest: def _make_request(**overrides) -> DeliveryCalculationRequest:
payload = { payload = {
"entity": DeliveryEntity.INDIVIDUAL, "entity": DeliveryEntity.INDIVIDUAL,
"from_city": " Moscow ", "from_city": 1,
"to_city": " Saint Petersburg ", "to_city": 2,
"country_code": " ru ",
"weight_kg": 1.235, "weight_kg": 1.235,
"length_cm": 10.04, "length_cm": 10.04,
"width_cm": 20.05, "width_cm": 20.05,
"height_cm": 30.06, "height_cm": 30.06,
} }
payload.update(overrides) payload.update(overrides)
return DeliveryRequest(**payload) return DeliveryCalculationRequest(**payload)
def test_normalize_delivery_request_applies_rules_without_side_effects() -> None: def test_normalize_delivery_request_applies_rules_without_side_effects() -> None:
@@ -45,20 +44,18 @@ def test_normalize_delivery_request_applies_rules_without_side_effects() -> None
normalized = normalize_delivery_request(request, weight_round_scale=2) normalized = normalize_delivery_request(request, weight_round_scale=2)
assert normalized is not request assert normalized is not request
assert normalized.from_city == "Moscow" assert normalized.from_city == 1
assert normalized.to_city == "Saint Petersburg" assert normalized.to_city == 2
assert normalized.country_code == "RU"
assert normalized.weight_kg == Decimal("1.24") assert normalized.weight_kg == Decimal("1.24")
assert normalized.length_cm == Decimal("10.0") assert normalized.length_cm == Decimal("10.0")
assert normalized.width_cm == Decimal("20.1") assert normalized.width_cm == Decimal("20.1")
assert normalized.height_cm == Decimal("30.1") assert normalized.height_cm == Decimal("30.1")
assert request.from_city == " Moscow " assert request.from_city == 1
assert request.country_code == " ru " assert request.to_city == 2
def test_normalize_delivery_request_handles_normalization_boundaries() -> None: def test_normalize_delivery_request_handles_normalization_boundaries() -> None:
request = _make_request( request = _make_request(
country_code=" ",
weight_kg=0.0001, weight_kg=0.0001,
length_cm=0.01, length_cm=0.01,
width_cm=0.04, width_cm=0.04,
@@ -73,7 +70,6 @@ def test_normalize_delivery_request_handles_normalization_boundaries() -> None:
assert low_scale.width_cm == Decimal("0.1") assert low_scale.width_cm == Decimal("0.1")
assert low_scale.height_cm == Decimal("0.2") assert low_scale.height_cm == Decimal("0.2")
assert high_scale.weight_kg == Decimal("0.01") assert high_scale.weight_kg == Decimal("0.01")
assert low_scale.country_code is None
def test_filter_valid_prices_handles_empty_input() -> None: def test_filter_valid_prices_handles_empty_input() -> None:
+26 -12
View File
@@ -4,7 +4,12 @@ from decimal import Decimal
import pytest import pytest
from app.adapters.delivery_providers.base import ProviderRequestError from app.adapters.delivery_providers.base import ProviderRequestError
from app.schemas.request import DeliveryEntity, DeliveryRequest, ParcelType from app.domain.price import normalize_delivery_request
from app.schemas.request import (
DeliveryCalculationRequest,
DeliveryEntity,
ParcelType,
)
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
from app.services.aggregator import AggregatorService, InvalidDeliveryRequestError from app.services.aggregator import AggregatorService, InvalidDeliveryRequestError
@@ -25,9 +30,11 @@ class StubProvider:
self._response = response self._response = response
self._error = error self._error = error
self.cache_ttl_seconds = cache_ttl_seconds self.cache_ttl_seconds = cache_ttl_seconds
self.calls: list[DeliveryRequest] = [] self.calls: list[DeliveryCalculationRequest] = []
async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]: async def get_prices(
self, request: DeliveryCalculationRequest
) -> list[DeliveryPrice]:
self.calls.append(request) self.calls.append(request)
if self._error is not None: if self._error is not None:
raise self._error raise self._error
@@ -58,12 +65,11 @@ class StubCache:
self._storage[key] = value self._storage[key] = value
def _make_request(**overrides: object) -> DeliveryRequest: def _make_request(**overrides: object) -> DeliveryCalculationRequest:
payload: dict[str, object] = { payload: dict[str, object] = {
"entity": DeliveryEntity.INDIVIDUAL, "entity": DeliveryEntity.INDIVIDUAL,
"from_city": "Moscow", "from_city": 1,
"to_city": "Kazan", "to_city": 2,
"country_code": None,
"weight_kg": 1.234, "weight_kg": 1.234,
"length_cm": 30.0, "length_cm": 30.0,
"width_cm": 20.0, "width_cm": 20.0,
@@ -71,7 +77,7 @@ def _make_request(**overrides: object) -> DeliveryRequest:
"parcel_type": None, "parcel_type": None,
} }
payload.update(overrides) payload.update(overrides)
return DeliveryRequest(**payload) return DeliveryCalculationRequest(**payload)
def _make_price(provider: str, price: str, *, service_name: str = "standard") -> DeliveryPrice: def _make_price(provider: str, price: str, *, service_name: str = "standard") -> DeliveryPrice:
@@ -118,15 +124,23 @@ def test_get_all_prices_full_success_returns_sorted_and_updates_cache() -> None:
assert [ttl for _, _, ttl in cache.set_calls] == [111, 222] assert [ttl for _, _, ttl in cache.set_calls] == [111, 222]
def test_get_all_prices_normalizes_and_forwards_country_code_to_providers() -> None: def test_get_all_prices_forwards_city_identifiers_and_uses_updated_cache_key() -> None:
provider = StubProvider(name="cdek", response=[_make_price("cdek", "150.00")]) provider = StubProvider(name="cdek", response=[_make_price("cdek", "150.00")])
service = AggregatorService([provider], cache=StubCache()) cache = StubCache()
service = AggregatorService([provider], cache=cache)
request = _make_request()
result = asyncio.run(service.get_all_prices(_make_request(country_code=" kz "))) result = asyncio.run(service.get_all_prices(request))
assert [price.provider for price in result] == ["cdek"] assert [price.provider for price in result] == ["cdek"]
assert len(provider.calls) == 1 assert len(provider.calls) == 1
assert provider.calls[0].country_code == "KZ" assert provider.calls[0].from_city == 1
assert provider.calls[0].to_city == 2
expected_key = AggregatorService._build_cache_key(
provider_name=provider.name,
request=normalize_delivery_request(request),
)
assert cache.get_calls == [expected_key]
def test_get_all_prices_partial_failure_excludes_failed_provider() -> None: def test_get_all_prices_partial_failure_excludes_failed_provider() -> None: