017 add creating order router
This commit is contained in:
@@ -6,12 +6,14 @@ from app.adapters.delivery_providers.cdek import CDEKProvider
|
||||
from app.config import Settings
|
||||
from app.controllers.http_client import build_controller_http_client
|
||||
from app.repositories.cache.redis_cache import PriceCache
|
||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||
from app.schemas.request import DeliveryRequest
|
||||
from app.schemas.response import DeliveryPrice
|
||||
from app.services.aggregator import (
|
||||
AggregatorService,
|
||||
AggregatorServiceError,
|
||||
InvalidDeliveryRequestError,
|
||||
InvalidOrderCreateRequestError,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/delivery", tags=["delivery"])
|
||||
@@ -24,16 +26,16 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||
http_client = build_controller_http_client(
|
||||
timeout_seconds=settings.adapter.cdek_timeout_seconds
|
||||
)
|
||||
providers = (
|
||||
CDEKProvider.from_adapter_config(
|
||||
http_client=http_client,
|
||||
adapter_config=settings.adapter,
|
||||
),
|
||||
cdek_provider = CDEKProvider.from_adapter_config(
|
||||
http_client=http_client,
|
||||
adapter_config=settings.adapter,
|
||||
)
|
||||
providers = (cdek_provider,)
|
||||
cache = PriceCache.from_repository_config(settings.repository)
|
||||
service = AggregatorService(
|
||||
providers=providers,
|
||||
cache=cache,
|
||||
order_adapter=cdek_provider,
|
||||
weight_round_scale=settings.business_logic.weight_round_scale,
|
||||
provider_price_multiplier=settings.business_logic.provider_price_multiplier,
|
||||
)
|
||||
@@ -77,3 +79,31 @@ async def get_delivery_price(
|
||||
"message": "Delivery price aggregation is temporarily unavailable.",
|
||||
},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/order",
|
||||
response_model=OrderCreateResponse,
|
||||
)
|
||||
async def create_delivery_order(
|
||||
order_request: OrderCreateRequest,
|
||||
service: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> OrderCreateResponse:
|
||||
try:
|
||||
return await service.create_order(order_request)
|
||||
except InvalidOrderCreateRequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "invalid_order_create_request",
|
||||
"message": "Order request contains invalid or unsupported CDEK data.",
|
||||
},
|
||||
) from exc
|
||||
except AggregatorServiceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"code": "order_creation_unavailable",
|
||||
"message": "CDEK order creation is temporarily unavailable.",
|
||||
},
|
||||
) from exc
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.domain.price import (
|
||||
filter_and_sort_prices,
|
||||
normalize_delivery_request,
|
||||
)
|
||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||
from app.schemas.request import DeliveryRequest
|
||||
from app.schemas.response import DeliveryPrice
|
||||
|
||||
@@ -27,6 +28,14 @@ class InvalidDeliveryRequestError(AggregatorServiceError):
|
||||
"""Raised when provider rejects delivery request as invalid."""
|
||||
|
||||
|
||||
class InvalidOrderCreateRequestError(AggregatorServiceError):
|
||||
"""Raised when provider rejects order creation payload as invalid."""
|
||||
|
||||
|
||||
class OrderCreationUnavailableError(AggregatorServiceError):
|
||||
"""Raised when order creation cannot be completed."""
|
||||
|
||||
|
||||
class PriceCacheProtocol(Protocol):
|
||||
async def get(self, key: str) -> object | None: ...
|
||||
|
||||
@@ -42,11 +51,18 @@ class FilterAndSortPricesFn(Protocol):
|
||||
) -> list[object]: ...
|
||||
|
||||
|
||||
class OrderRegistrationAdapterProtocol(Protocol):
|
||||
async def register_order(
|
||||
self, request: OrderCreateRequest
|
||||
) -> OrderCreateResponse: ...
|
||||
|
||||
|
||||
class AggregatorService:
|
||||
def __init__(
|
||||
self,
|
||||
providers: Sequence[DeliveryProvider],
|
||||
cache: PriceCacheProtocol | None = None,
|
||||
order_adapter: OrderRegistrationAdapterProtocol | None = None,
|
||||
*,
|
||||
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
|
||||
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||
@@ -54,6 +70,7 @@ class AggregatorService:
|
||||
) -> None:
|
||||
self._providers = tuple(providers)
|
||||
self._cache = cache
|
||||
self._order_adapter = order_adapter
|
||||
self._weight_round_scale = weight_round_scale
|
||||
self._provider_price_multiplier = provider_price_multiplier
|
||||
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
||||
@@ -103,6 +120,28 @@ class AggregatorService:
|
||||
)
|
||||
return [self._coerce_delivery_price(price) for price in filtered_and_sorted]
|
||||
|
||||
async def create_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
||||
if self._order_adapter is None:
|
||||
raise OrderCreationUnavailableError(
|
||||
"Order registration adapter is not configured."
|
||||
)
|
||||
|
||||
try:
|
||||
created_order = await self._order_adapter.register_order(request)
|
||||
except ProviderRequestError as exc:
|
||||
raise InvalidOrderCreateRequestError(
|
||||
"Order request is invalid for the configured provider."
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise OrderCreationUnavailableError(
|
||||
"Order creation is temporarily unavailable."
|
||||
) from exc
|
||||
|
||||
return OrderCreateResponse.model_validate(
|
||||
created_order,
|
||||
from_attributes=True,
|
||||
)
|
||||
|
||||
async def _get_provider_price(
|
||||
self,
|
||||
*,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Spec Tasks Index
|
||||
|
||||
> ⚠️ This file is generated. Do not edit manually.
|
||||
> Generated at (UTC): `2026-03-14T00:55:12+00:00`
|
||||
> Generated at (UTC): `2026-03-14T00:57:33+00:00`
|
||||
|
||||
## Tasks
|
||||
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ delivery_days_max: int
|
||||
### Входная: `OrderCreateRequest`
|
||||
```
|
||||
type: Literal[2]
|
||||
tariff_code: Literal[136]
|
||||
tariff_code: Literal[535]
|
||||
comment: str | None
|
||||
sender:
|
||||
company: str | None
|
||||
|
||||
@@ -9,7 +9,7 @@ created: 2026-03-14
|
||||
Сейчас 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`.
|
||||
Добавить `POST /api/v1/delivery/order` в существующий controller и существующий service с request/response schemas и error mapping для регистрации заказа в CDEK через adapter contract из задачи `016`.
|
||||
|
||||
## Constraints
|
||||
- Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses.
|
||||
@@ -18,15 +18,16 @@ created: 2026-03-14
|
||||
- Логика создания заказа должна быть добавлена в существующий service модуль `app/services/aggregator.py`; не создавать отдельный service модуль.
|
||||
- Service оркестрирует только вызов injected CDEK order adapter и не содержит business logic или provider HTTP-деталей.
|
||||
- Контракт входного запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`.
|
||||
- В scope задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы.
|
||||
- Scope задачи не включает кеширование, агрегацию тарифов, расчёт стоимости, новые провайдеры и расширение order flow за пределы CDEK.
|
||||
- Не изменять файлы в `spec/`.
|
||||
|
||||
## Acceptance criteria
|
||||
- Существует endpoint `POST /api/v1/order`, принимающий payload по контракту из `http-client.http`.
|
||||
- Реализованы request/response schemas для создания заказа и минимального успешного ответа API.
|
||||
- Существует endpoint `POST /api/v1/delivery/order`, принимающий payload по контракту из `http-client.http`.
|
||||
- Реализованы request/response schemas `OrderCreateRequest` и `OrderCreateResponse` для создания заказа.
|
||||
- Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`.
|
||||
- Controller делегирует обработку только в `AggregatorService.create_order()`.
|
||||
- Service вызывает injected adapter для регистрации заказа и возвращает response model с `order_uuid`.
|
||||
- Service вызывает injected adapter для регистрации заказа и возвращает `OrderCreateResponse` с `provider` и `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.
|
||||
@@ -34,7 +35,7 @@ created: 2026-03-14
|
||||
## Definition of Done
|
||||
- [ ] Добавлены order request/response schemas.
|
||||
- [ ] Реализован метод `AggregatorService.create_order()` для оркестрации регистрации заказа.
|
||||
- [ ] Реализован endpoint `POST /api/v1/order` в существующем controller.
|
||||
- [ ] Реализован endpoint `POST /api/v1/delivery/order` в существующем controller.
|
||||
- [ ] Добавлены service и controller/API tests для order creation flow.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.controllers.v1.delivery import get_aggregator_service
|
||||
from app.main import create_app
|
||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||
from app.services.aggregator import (
|
||||
AggregatorServiceError,
|
||||
InvalidOrderCreateRequestError,
|
||||
)
|
||||
|
||||
|
||||
class StubAggregatorService:
|
||||
def __init__(self, *, response: object, error: Exception | None = None) -> None:
|
||||
self._response = response
|
||||
self._error = error
|
||||
self.calls: list[OrderCreateRequest] = []
|
||||
|
||||
async def create_order(self, request: OrderCreateRequest) -> object:
|
||||
self.calls.append(request)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._response
|
||||
|
||||
|
||||
def _install_service_override(app, service: StubAggregatorService) -> None:
|
||||
async def override_service() -> StubAggregatorService:
|
||||
return service
|
||||
|
||||
app.dependency_overrides[get_aggregator_service] = override_service
|
||||
|
||||
|
||||
def _valid_payload() -> dict[str, object]:
|
||||
return {
|
||||
"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",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_post_delivery_order_returns_response_and_delegates_to_service() -> None:
|
||||
expected_response = OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
|
||||
service = StubAggregatorService(response=expected_response)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post("/api/v1/delivery/order", json=_valid_payload())
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == expected_response.model_dump(mode="json")
|
||||
assert service.calls == [OrderCreateRequest(**_valid_payload())]
|
||||
|
||||
|
||||
def test_post_delivery_order_rejects_invalid_payload() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = _valid_payload()
|
||||
invalid_payload["tariff_code"] = 136
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post("/api/v1/delivery/order", json=invalid_payload)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_delivery_order_maps_invalid_request_to_400() -> None:
|
||||
service = StubAggregatorService(
|
||||
response=None,
|
||||
error=InvalidOrderCreateRequestError("invalid payload"),
|
||||
)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post("/api/v1/delivery/order", json=_valid_payload())
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"detail": {
|
||||
"code": "invalid_order_create_request",
|
||||
"message": "Order request contains invalid or unsupported CDEK data.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_post_delivery_order_maps_service_exception_to_503() -> None:
|
||||
service = StubAggregatorService(
|
||||
response=None,
|
||||
error=AggregatorServiceError("service unavailable"),
|
||||
)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post("/api/v1/delivery/order", json=_valid_payload())
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"detail": {
|
||||
"code": "order_creation_unavailable",
|
||||
"message": "CDEK order creation is temporarily unavailable.",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.delivery_providers.base import ProviderRequestError
|
||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||
from app.services.aggregator import (
|
||||
AggregatorService,
|
||||
InvalidOrderCreateRequestError,
|
||||
OrderCreationUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
class StubOrderAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
response: OrderCreateResponse | None = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self._response = response
|
||||
self._error = error
|
||||
self.calls: list[OrderCreateRequest] = []
|
||||
|
||||
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
||||
self.calls.append(request)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
if self._response is None:
|
||||
raise RuntimeError("Stub order adapter has no response configured.")
|
||||
return self._response
|
||||
|
||||
|
||||
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_create_order_delegates_to_adapter_and_returns_created_order() -> None:
|
||||
request = _make_order_request()
|
||||
adapter = StubOrderAdapter(
|
||||
response=OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
|
||||
)
|
||||
service = AggregatorService(providers=[], order_adapter=adapter)
|
||||
|
||||
result = asyncio.run(service.create_order(request))
|
||||
|
||||
assert result == OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
|
||||
assert adapter.calls == [request]
|
||||
|
||||
|
||||
def test_create_order_maps_provider_request_errors_to_invalid_order_error() -> None:
|
||||
request = _make_order_request()
|
||||
adapter = StubOrderAdapter(error=ProviderRequestError("bad payload"))
|
||||
service = AggregatorService(providers=[], order_adapter=adapter)
|
||||
|
||||
with pytest.raises(InvalidOrderCreateRequestError):
|
||||
asyncio.run(service.create_order(request))
|
||||
|
||||
assert adapter.calls == [request]
|
||||
|
||||
|
||||
def test_create_order_maps_transport_failures_to_unavailable_error() -> None:
|
||||
request = _make_order_request()
|
||||
adapter = StubOrderAdapter(error=RuntimeError("transport down"))
|
||||
service = AggregatorService(providers=[], order_adapter=adapter)
|
||||
|
||||
with pytest.raises(OrderCreationUnavailableError):
|
||||
asyncio.run(service.create_order(request))
|
||||
|
||||
assert adapter.calls == [request]
|
||||
|
||||
|
||||
def test_create_order_without_configured_adapter_raises_unavailable_error() -> None:
|
||||
service = AggregatorService(providers=[])
|
||||
|
||||
with pytest.raises(OrderCreationUnavailableError):
|
||||
asyncio.run(service.create_order(_make_order_request()))
|
||||
Reference in New Issue
Block a user