020 update city code resolving
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
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.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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user