016 add creating order to adapter
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
*.iml
|
*.iml
|
||||||
/config.yaml
|
/config.yaml
|
||||||
__pycache__
|
__pycache__
|
||||||
|
http-client.private.env.json
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import httpx
|
|||||||
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
|
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
|
||||||
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
||||||
from app.adapters.delivery_providers.cdek.mapper import map_cdek_response
|
from app.adapters.delivery_providers.cdek.mapper import map_cdek_response
|
||||||
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||||
|
CDEKOrderMappingError,
|
||||||
|
map_cdek_order_request,
|
||||||
|
map_cdek_order_response,
|
||||||
|
)
|
||||||
from app.config import AdapterConfig
|
from app.config import AdapterConfig
|
||||||
|
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||||
from app.schemas.request import DeliveryRequest
|
from app.schemas.request import DeliveryRequest
|
||||||
from app.schemas.response import DeliveryPrice
|
from app.schemas.response import DeliveryPrice
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -41,6 +47,7 @@ class CDEKClient:
|
|||||||
normalized_base_url = base_url.rstrip("/")
|
normalized_base_url = base_url.rstrip("/")
|
||||||
self._city_lookup_url = f"{normalized_base_url}/location/suggest/cities"
|
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._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
|
||||||
@@ -86,6 +93,62 @@ class CDEKClient:
|
|||||||
|
|
||||||
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
||||||
|
|
||||||
|
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
||||||
|
payload = map_cdek_order_request(request)
|
||||||
|
for attempt in range(self._retry_attempts + 1):
|
||||||
|
try:
|
||||||
|
token = await self._auth_client.get_access_token()
|
||||||
|
response = await self._http_client.post(
|
||||||
|
self._orders_url,
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=self._timeout_seconds,
|
||||||
|
)
|
||||||
|
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||||
|
if attempt < self._retry_attempts:
|
||||||
|
await self._sleep(self._retry_delay(attempt))
|
||||||
|
continue
|
||||||
|
raise CDEKClientError(
|
||||||
|
"CDEK order registration 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(
|
||||||
|
"CDEK order registration failed with retriable status "
|
||||||
|
f"{response.status_code}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
raise CDEKRequestError(
|
||||||
|
"CDEK order registration request was rejected with status "
|
||||||
|
f"{response.status_code}."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
raw_payload = response.json()
|
||||||
|
except (httpx.HTTPError, TypeError, ValueError) as exc:
|
||||||
|
raise CDEKClientError(
|
||||||
|
"CDEK order registration returned invalid payload."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(raw_payload, dict):
|
||||||
|
raise CDEKClientError(
|
||||||
|
"CDEK order registration payload must be a JSON object."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return map_cdek_order_response(raw_payload)
|
||||||
|
except CDEKOrderMappingError as exc:
|
||||||
|
raise CDEKClientError(
|
||||||
|
"CDEK order registration response payload is invalid."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
raise CDEKClientError("CDEK order registration failed unexpectedly.")
|
||||||
|
|
||||||
def _retry_delay(self, attempt: int) -> float:
|
def _retry_delay(self, attempt: int) -> float:
|
||||||
return self._retry_backoff_seconds * (attempt + 1)
|
return self._retry_backoff_seconds * (attempt + 1)
|
||||||
|
|
||||||
@@ -221,3 +284,6 @@ class CDEKProvider(DeliveryProvider):
|
|||||||
async def get_price(self, request: DeliveryRequest) -> DeliveryPrice:
|
async def get_price(self, request: DeliveryRequest) -> 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)
|
||||||
|
|
||||||
|
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
||||||
|
return await self._client.register_order(request)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""CDEK order registration payload mappers."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||||
|
|
||||||
|
|
||||||
|
class CDEKOrderMappingError(ValueError):
|
||||||
|
"""Raised when CDEK order payload cannot be mapped."""
|
||||||
|
|
||||||
|
|
||||||
|
def map_cdek_order_request(request: OrderCreateRequest) -> dict[str, Any]:
|
||||||
|
return request.model_dump(mode="python", exclude_none=True)
|
||||||
|
|
||||||
|
|
||||||
|
def map_cdek_order_response(payload: dict[str, Any]) -> OrderCreateResponse:
|
||||||
|
entity = payload.get("entity")
|
||||||
|
if not isinstance(entity, dict):
|
||||||
|
raise CDEKOrderMappingError("CDEK order response must include entity object.")
|
||||||
|
|
||||||
|
order_uuid = entity.get("uuid")
|
||||||
|
if not isinstance(order_uuid, str) or not order_uuid:
|
||||||
|
raise CDEKOrderMappingError("CDEK order response must include entity.uuid.")
|
||||||
|
|
||||||
|
return OrderCreateResponse(provider="cdek", order_uuid=order_uuid)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Schemas for CDEK order registration."""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPhone(BaseModel):
|
||||||
|
number: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderParty(BaseModel):
|
||||||
|
company: str | None = None
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
email: str = Field(min_length=1)
|
||||||
|
phones: list[OrderPhone] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderLocation(BaseModel):
|
||||||
|
address: str = Field(min_length=1)
|
||||||
|
city: str = Field(min_length=1)
|
||||||
|
country_code: str = Field(min_length=2, max_length=2)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderService(BaseModel):
|
||||||
|
code: str = Field(min_length=1)
|
||||||
|
parameter: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderPackage(BaseModel):
|
||||||
|
number: str = Field(min_length=1)
|
||||||
|
weight: int = Field(gt=0)
|
||||||
|
length: int = Field(gt=0)
|
||||||
|
width: int = Field(gt=0)
|
||||||
|
height: int = Field(gt=0)
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderCreateRequest(BaseModel):
|
||||||
|
type: Literal[2]
|
||||||
|
tariff_code: Literal[535]
|
||||||
|
comment: str | None = None
|
||||||
|
sender: OrderParty
|
||||||
|
recipient: OrderParty
|
||||||
|
from_location: OrderLocation
|
||||||
|
to_location: OrderLocation
|
||||||
|
services: list[OrderService] = Field(min_length=1)
|
||||||
|
packages: list[OrderPackage] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderCreateResponse(BaseModel):
|
||||||
|
provider: str = Field(min_length=1)
|
||||||
|
order_uuid: str = Field(min_length=1)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"test": {
|
||||||
|
"base_url": "https://api.edu.cdek.ru"
|
||||||
|
},
|
||||||
|
"prod": {
|
||||||
|
"base_url": "https://api.cdek.ru"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
### 1. Получение токена авторизации CDEK
|
||||||
|
POST {{base_url}}/v2/oauth/token
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
grant_type=client_credentials&client_id={{client_id}}&client_secret={{client_secret}}
|
||||||
|
|
||||||
|
> {%
|
||||||
|
client.global.set("auth_token", response.body.access_token);
|
||||||
|
%}
|
||||||
|
|
||||||
|
### 2. Получение информации о заказе по номеру СДЭК
|
||||||
|
GET {{base_url}}/v2/orders?cdek_number=10240410458
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### 3. Регистрация заказа (тип "доставка", до двери)
|
||||||
|
POST {{base_url}}/v2/orders
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": 2,
|
||||||
|
"tariff_code": 535,
|
||||||
|
"comment": "Тестовый заказ",
|
||||||
|
"sender": {
|
||||||
|
"company": "ООО Ромашка",
|
||||||
|
"name": "Петр Петров",
|
||||||
|
"email": "sender@example.com",
|
||||||
|
"phones": [
|
||||||
|
{
|
||||||
|
"number": "+79009876543"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recipient": {
|
||||||
|
"name": "Иван Иванов",
|
||||||
|
"email": "ivan@example.com",
|
||||||
|
"phones": [
|
||||||
|
{
|
||||||
|
"number": "+79001234567"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"from_location": {
|
||||||
|
"address": "ул. Ленина, 1",
|
||||||
|
"city": "Москва",
|
||||||
|
"country_code": "RU"
|
||||||
|
},
|
||||||
|
"to_location": {
|
||||||
|
"address": "ул. Пушкина, 10",
|
||||||
|
"city": "Новосибирск",
|
||||||
|
"country_code": "RU"
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"code": "INSURANCE",
|
||||||
|
"parameter": "1000"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"number": "1",
|
||||||
|
"weight": 1000,
|
||||||
|
"length": 20,
|
||||||
|
"width": 15,
|
||||||
|
"height": 10,
|
||||||
|
"comment": "Упаковка 1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
> {%
|
||||||
|
client.global.set("order_uuid", response.body.entity.uuid);
|
||||||
|
%}
|
||||||
+6
-4
@@ -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-13T14:21:47+00:00`
|
> Generated at (UTC): `2026-03-14T00:55:12+00:00`
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
@@ -22,10 +22,12 @@
|
|||||||
| 012 | DONE | 2026-03-08 | Remove observability and SigNoz stack for phase 1 | `spec/tasks/012_remove_observability_and_signoz_for_phase1.md` |
|
| 012 | DONE | 2026-03-08 | Remove observability and SigNoz stack for phase 1 | `spec/tasks/012_remove_observability_and_signoz_for_phase1.md` |
|
||||||
| 013 | DONE | 2026-03-09 | Add configurable provider price multiplier in domain logic | `spec/tasks/013_add_configurable_provider_price_multiplier.md` |
|
| 013 | DONE | 2026-03-09 | Add configurable provider price multiplier in domain logic | `spec/tasks/013_add_configurable_provider_price_multiplier.md` |
|
||||||
| 014 | DONE | 2026-03-12 | Add minimal structlog JSON logging | `spec/tasks/014_add_minimal_structlog_json_logging.md` |
|
| 014 | DONE | 2026-03-12 | Add minimal structlog JSON logging | `spec/tasks/014_add_minimal_structlog_json_logging.md` |
|
||||||
| 015 | TODO | 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` |
|
||||||
|
| 017 | TODO | 2026-03-14 | Add CDEK order creation endpoint | `spec/tasks/017_add_cdek_order_creation_endpoint.md` |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- Total: **16**
|
- Total: **18**
|
||||||
- TODO: **1**
|
- TODO: **1**
|
||||||
- DONE: **15**
|
- DONE: **17**
|
||||||
|
|||||||
+47
-6
@@ -16,6 +16,7 @@
|
|||||||
## Продуктовые требования
|
## Продуктовые требования
|
||||||
|
|
||||||
- Принимать запрос на расчёт стоимости доставки (откуда, куда, вес, габариты)
|
- Принимать запрос на расчёт стоимости доставки (откуда, куда, вес, габариты)
|
||||||
|
- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери"
|
||||||
- Опрашивать всех зарегистрированных провайдеров параллельно
|
- Опрашивать всех зарегистрированных провайдеров параллельно
|
||||||
- Возвращать унифицированный список тарифов, отсортированных по цене
|
- Возвращать унифицированный список тарифов, отсортированных по цене
|
||||||
- Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком
|
- Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
### Фаза 1
|
### Фаза 1
|
||||||
- **CDEK** — https://apidoc.cdek.ru/
|
- **CDEK** — https://apidoc.cdek.ru/
|
||||||
- Аутентификация: OAuth2 (client credentials)
|
- Аутентификация: OAuth2 (client credentials)
|
||||||
- Операции: расчёт тарифа
|
- Операции: расчёт тарифа, регистрация заказа
|
||||||
- Cache TTL: 15 минут
|
- Cache TTL: 15 минут
|
||||||
|
|
||||||
### Фаза 2+
|
### Фаза 2+
|
||||||
@@ -41,16 +42,19 @@
|
|||||||
|
|
||||||
### 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` — принимает `DeliveryRequest`, возвращает `list[DeliveryPrice]`
|
||||||
|
- `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse`
|
||||||
- Парсинг и валидация входных данных через Pydantic
|
- Парсинг и валидация входных данных через Pydantic
|
||||||
- Маппинг исключений сервиса в HTTP-ответы
|
- Маппинг исключений сервиса в HTTP-ответы
|
||||||
- Вызывает ровно один метод Service: `AggregatorService.get_all_prices()`
|
- Каждый 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: DeliveryRequest) -> list[DeliveryPrice]`
|
||||||
- Распределяет запросы по всем зарегистрированным провайдерам через `asyncio.gather(..., return_exceptions=True)`
|
- Распределяет запросы по всем зарегистрированным провайдерам через `asyncio.gather(..., return_exceptions=True)`
|
||||||
- Фильтрует упавшие результаты
|
- Фильтрует упавшие результаты
|
||||||
- Сортирует тарифы по цене
|
- Сортирует тарифы по цене
|
||||||
- Не содержит бизнес-логики и логики, специфичной для провайдеров
|
- `AggregatorService.create_order(request: OrderCreateRequest) -> OrderCreateResponse`
|
||||||
|
- `AggregatorService.create_order()` оркестрирует регистрацию заказа в CDEK через injected adapter dependency
|
||||||
|
- Service не содержит бизнес-логики и provider HTTP-деталей
|
||||||
|
|
||||||
### Business Logic (`app/domain/`)
|
### Business Logic (`app/domain/`)
|
||||||
- Правила сортировки и фильтрации тарифов
|
- Правила сортировки и фильтрации тарифов
|
||||||
@@ -75,7 +79,10 @@
|
|||||||
- `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 → `DeliveryPrice`
|
- `cdek/mapper.py` — ответ CDEK → `DeliveryPrice`
|
||||||
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только `get_price()`
|
- `cdek/order_mapper.py` — request/response mapping для регистрации заказа CDEK
|
||||||
|
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
|
||||||
|
|
||||||
|
Для сценария создания заказа CDEK adapter принимает валидированную order model, отправляет контракт `Регистрация заказа (тип "доставка", до двери)` из `http-client.http` и возвращает внутреннюю response model без утечки HTTP-деталей в Service.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,6 +109,38 @@ delivery_days_min: int
|
|||||||
delivery_days_max: int
|
delivery_days_max: int
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Входная: `OrderCreateRequest`
|
||||||
|
```
|
||||||
|
type: Literal[2]
|
||||||
|
tariff_code: Literal[136]
|
||||||
|
comment: str | None
|
||||||
|
sender:
|
||||||
|
company: str | None
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
phones: list[{number: str}]
|
||||||
|
recipient:
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
phones: list[{number: str}]
|
||||||
|
from_location:
|
||||||
|
address: str
|
||||||
|
city: str
|
||||||
|
country_code: str
|
||||||
|
to_location:
|
||||||
|
address: str
|
||||||
|
city: str
|
||||||
|
country_code: str
|
||||||
|
services: list[{code: str, parameter: str}]
|
||||||
|
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Выходная: `OrderCreateResponse`
|
||||||
|
```
|
||||||
|
provider: str
|
||||||
|
order_uuid: str
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Технологический стек
|
## Технологический стек
|
||||||
@@ -135,13 +174,15 @@ app/
|
|||||||
│ └── cdek/
|
│ └── cdek/
|
||||||
│ ├── client.py
|
│ ├── client.py
|
||||||
│ ├── auth.py
|
│ ├── auth.py
|
||||||
│ └── mapper.py
|
│ ├── mapper.py
|
||||||
|
│ └── order_mapper.py
|
||||||
├── repositories/
|
├── repositories/
|
||||||
│ └── cache/
|
│ └── cache/
|
||||||
│ └── redis_cache.py # Repository
|
│ └── redis_cache.py # Repository
|
||||||
├── schemas/
|
├── schemas/
|
||||||
│ ├── request.py
|
│ ├── request.py
|
||||||
│ └── response.py
|
│ ├── response.py
|
||||||
|
│ └── order.py
|
||||||
└── config.py
|
└── config.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: 015
|
id: 015
|
||||||
title: Add minimal OpenTelemetry tracing
|
title: Add minimal OpenTelemetry tracing
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-03-13
|
created: 2026-03-13
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
id: 016
|
||||||
|
title: Add CDEK order registration adapter
|
||||||
|
status: DONE
|
||||||
|
created: 2026-03-14
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Сейчас CDEK adapter поддерживает только расчёт тарифа. Новый сценарий требует регистрацию заказа в CDEK по контракту из `http-client.http` в разделе `Регистрация заказа (тип "доставка", до двери)`.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Расширить существующий CDEK adapter регистрацией заказа: добавить в текущий adapter/provider сбор payload по контракту, вызов `POST /v2/orders`, детерминированную обработку provider errors и маппинг успешного ответа во внутреннюю response model.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- Изменения ограничены существующим Adapter layer и schema/mapper моделями, необходимыми для стабильного adapter contract.
|
||||||
|
- Использовать существующий OAuth2 flow CDEK; не дублировать auth logic.
|
||||||
|
- Контракт запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`.
|
||||||
|
- В scope этой задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы.
|
||||||
|
- Не добавлять новые controller/service модули, controller routing, service orchestration, cache behavior и business logic.
|
||||||
|
- Не изменять файлы в `spec/`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- Существующий CDEK adapter/provider предоставляет стабильный метод регистрации заказа, принимающий валидированную internal model вместо raw dict.
|
||||||
|
- Adapter отправляет `POST /v2/orders` с bearer token и JSON payload, соответствующим контракту из `http-client.http`.
|
||||||
|
- Успешный ответ CDEK маппится во внутреннюю response model с `order_uuid`, полученным из `entity.uuid`.
|
||||||
|
- Provider validation errors класса 4xx маппятся в детерминированную provider request error, а transport/5xx ошибки — в adapter client error.
|
||||||
|
- Adapter tests покрывают success case, payload mapping, response mapping и error handling для order registration.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- [ ] Расширен существующий adapter method set для регистрации заказа CDEK.
|
||||||
|
- [ ] Реализованы request/response mapper(s) для order registration.
|
||||||
|
- [ ] Переиспользуется существующий OAuth2 auth client.
|
||||||
|
- [ ] Добавлены adapter tests для order registration сценария.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Добавить `tests/adapters/delivery_providers/cdek/test_order_client.py` для success/error сценариев регистрации заказа.
|
||||||
|
- Проверить, что payload содержит поля из контракта `http-client.http`.
|
||||||
|
- Проверить маппинг `entity.uuid` в внутреннюю response model.
|
||||||
|
- Для внешних HTTP взаимодействий использовать stubs/mocks.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q`
|
||||||
|
- `python3 spec/gen_spec_index.py --check`
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
id: 017
|
||||||
|
title: Add CDEK order creation endpoint
|
||||||
|
status: TODO
|
||||||
|
created: 2026-03-14
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Сейчас API поддерживает только `POST /api/v1/delivery/price`. Для нового пользовательского сценария нужен отдельный endpoint `/order`, который создаёт заказ в CDEK по контракту из `http-client.http`.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Добавить `POST /api/v1/order` в существующий controller и существующий service с request/response schema и error mapping для регистрации заказа в CDEK через adapter contract из задачи `016`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses.
|
||||||
|
- Endpoint должен вызывать ровно один метод Service: `AggregatorService.create_order()`.
|
||||||
|
- Новый endpoint должен быть добавлен в существующий controller модуль `app/controllers/v1/delivery.py`; не создавать отдельный controller модуль.
|
||||||
|
- Логика создания заказа должна быть добавлена в существующий service модуль `app/services/aggregator.py`; не создавать отдельный service модуль.
|
||||||
|
- Service оркестрирует только вызов injected CDEK order adapter и не содержит business logic или provider HTTP-деталей.
|
||||||
|
- Контракт входного запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`.
|
||||||
|
- Scope задачи не включает кеширование, агрегацию тарифов, расчёт стоимости, новые провайдеры и расширение order flow за пределы CDEK.
|
||||||
|
- Не изменять файлы в `spec/`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- Существует endpoint `POST /api/v1/order`, принимающий payload по контракту из `http-client.http`.
|
||||||
|
- Реализованы request/response schemas для создания заказа и минимального успешного ответа API.
|
||||||
|
- Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`.
|
||||||
|
- Controller делегирует обработку только в `AggregatorService.create_order()`.
|
||||||
|
- Service вызывает injected adapter для регистрации заказа и возвращает response model с `order_uuid`.
|
||||||
|
- Логика orchestration размещена в существующем service `app/services/aggregator.py`.
|
||||||
|
- Ошибки валидации входного payload возвращают 422, provider request errors маппятся в 400, недоступность CDEK и transport failures — в 503.
|
||||||
|
- API и service tests покрывают success case и основные failure scenarios.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- [ ] Добавлены order request/response schemas.
|
||||||
|
- [ ] Реализован метод `AggregatorService.create_order()` для оркестрации регистрации заказа.
|
||||||
|
- [ ] Реализован endpoint `POST /api/v1/order` в существующем controller.
|
||||||
|
- [ ] Добавлены service и controller/API tests для order creation flow.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Добавить `tests/services/test_order.py` для проверки вызова adapter и маппинга ошибок сервиса.
|
||||||
|
- Добавить `tests/controllers/v1/test_order.py` для success case, schema validation и HTTP mapping ошибок.
|
||||||
|
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки подключения нового endpoint и service wiring без новых модулей.
|
||||||
|
- Использовать test doubles для adapter dependency.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- `poetry run pytest tests/services/test_order.py -q`
|
||||||
|
- `poetry run pytest tests/controllers/v1/test_order.py -q`
|
||||||
|
- `poetry run pytest tests/smoke/test_app_import.py -q`
|
||||||
|
- `python3 spec/gen_spec_index.py --check`
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cdek.client import (
|
||||||
|
CDEKClient,
|
||||||
|
CDEKClientError,
|
||||||
|
CDEKProvider,
|
||||||
|
CDEKRequestError,
|
||||||
|
)
|
||||||
|
from app.schemas.order import OrderCreateRequest
|
||||||
|
|
||||||
|
|
||||||
|
class StubAuthClient:
|
||||||
|
async def get_access_token(self) -> str:
|
||||||
|
return "test-token"
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceHTTPClient:
|
||||||
|
def __init__(self, results: list[Any]) -> None:
|
||||||
|
self._results = results
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _next_result(self) -> Any:
|
||||||
|
return self._results[len(self.calls) - 1]
|
||||||
|
|
||||||
|
async def post(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": url,
|
||||||
|
"json": json,
|
||||||
|
"data": data,
|
||||||
|
"headers": headers,
|
||||||
|
"timeout": timeout,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = self._next_result()
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _make_order_request(**overrides: object) -> OrderCreateRequest:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"type": 2,
|
||||||
|
"tariff_code": 535,
|
||||||
|
"comment": "Test order",
|
||||||
|
"sender": {
|
||||||
|
"company": "Romashka LLC",
|
||||||
|
"name": "Petr Petrov",
|
||||||
|
"email": "sender@example.com",
|
||||||
|
"phones": [{"number": "+79009876543"}],
|
||||||
|
},
|
||||||
|
"recipient": {
|
||||||
|
"name": "Ivan Ivanov",
|
||||||
|
"email": "ivan@example.com",
|
||||||
|
"phones": [{"number": "+79001234567"}],
|
||||||
|
},
|
||||||
|
"from_location": {
|
||||||
|
"address": "Lenina 1",
|
||||||
|
"city": "Moscow",
|
||||||
|
"country_code": "RU",
|
||||||
|
},
|
||||||
|
"to_location": {
|
||||||
|
"address": "Pushkina 10",
|
||||||
|
"city": "Novosibirsk",
|
||||||
|
"country_code": "RU",
|
||||||
|
},
|
||||||
|
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"number": "1",
|
||||||
|
"weight": 1000,
|
||||||
|
"length": 20,
|
||||||
|
"width": 15,
|
||||||
|
"height": 10,
|
||||||
|
"comment": "Package 1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return OrderCreateRequest(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_register_order_posts_cdek_contract_payload_and_maps_response() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"entity": {"uuid": "cdek-order-uuid"}},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
provider = CDEKProvider(
|
||||||
|
CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
timeout_seconds=7.5,
|
||||||
|
retry_attempts=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(provider.register_order(_make_order_request()))
|
||||||
|
|
||||||
|
assert result.provider == "cdek"
|
||||||
|
assert result.order_uuid == "cdek-order-uuid"
|
||||||
|
assert http_client.calls == [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.cdek.test/v2/orders",
|
||||||
|
"json": {
|
||||||
|
"type": 2,
|
||||||
|
"tariff_code": 535,
|
||||||
|
"comment": "Test order",
|
||||||
|
"sender": {
|
||||||
|
"company": "Romashka LLC",
|
||||||
|
"name": "Petr Petrov",
|
||||||
|
"email": "sender@example.com",
|
||||||
|
"phones": [{"number": "+79009876543"}],
|
||||||
|
},
|
||||||
|
"recipient": {
|
||||||
|
"name": "Ivan Ivanov",
|
||||||
|
"email": "ivan@example.com",
|
||||||
|
"phones": [{"number": "+79001234567"}],
|
||||||
|
},
|
||||||
|
"from_location": {
|
||||||
|
"address": "Lenina 1",
|
||||||
|
"city": "Moscow",
|
||||||
|
"country_code": "RU",
|
||||||
|
},
|
||||||
|
"to_location": {
|
||||||
|
"address": "Pushkina 10",
|
||||||
|
"city": "Novosibirsk",
|
||||||
|
"country_code": "RU",
|
||||||
|
},
|
||||||
|
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"number": "1",
|
||||||
|
"weight": 1000,
|
||||||
|
"length": 20,
|
||||||
|
"width": 15,
|
||||||
|
"height": 10,
|
||||||
|
"comment": "Package 1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"data": None,
|
||||||
|
"headers": {"Authorization": "Bearer test-token"},
|
||||||
|
"timeout": 7.5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
|
||||||
|
rejected_response = httpx.Response(
|
||||||
|
422,
|
||||||
|
json={"errors": [{"code": "v1", "message": "bad request"}]},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([rejected_response])
|
||||||
|
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=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKRequestError, match="status 422"):
|
||||||
|
asyncio.run(client.register_order(_make_order_request()))
|
||||||
|
|
||||||
|
assert len(http_client.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> None:
|
||||||
|
first_response = httpx.Response(
|
||||||
|
503,
|
||||||
|
json={"message": "temporary failure"},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||||
|
)
|
||||||
|
second_response = httpx.Response(
|
||||||
|
503,
|
||||||
|
json={"message": "temporary failure"},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([first_response, second_response])
|
||||||
|
sleep_calls: list[float] = []
|
||||||
|
|
||||||
|
async def fake_sleep(seconds: float) -> None:
|
||||||
|
sleep_calls.append(seconds)
|
||||||
|
|
||||||
|
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=1,
|
||||||
|
retry_backoff_seconds=0.25,
|
||||||
|
sleep=fake_sleep,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKClientError, match="retriable status 503"):
|
||||||
|
asyncio.run(client.register_order(_make_order_request()))
|
||||||
|
|
||||||
|
assert len(http_client.calls) == 2
|
||||||
|
assert sleep_calls == [0.25]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cdek_client_register_order_raises_client_error_for_invalid_success_payload() -> None:
|
||||||
|
invalid_response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"entity": {}},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([invalid_response])
|
||||||
|
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(CDEKClientError, match="response payload is invalid"):
|
||||||
|
asyncio.run(client.register_order(_make_order_request()))
|
||||||
Reference in New Issue
Block a user