diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py index 58f84c4..7c26a18 100644 --- a/app/controllers/v1/delivery.py +++ b/app/controllers/v1/delivery.py @@ -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 diff --git a/app/services/aggregator.py b/app/services/aggregator.py index 93bd41a..40a715f 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -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, *, diff --git a/spec/index.md b/spec/index.md index 714794f..8216196 100644 --- a/spec/index.md +++ b/spec/index.md @@ -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 diff --git a/spec/overview.md b/spec/overview.md index 8c34570..adc79cd 100644 --- a/spec/overview.md +++ b/spec/overview.md @@ -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 diff --git a/spec/tasks/017_add_cdek_order_creation_endpoint.md b/spec/tasks/017_add_cdek_order_creation_endpoint.md index f1c3657..54ab142 100644 --- a/spec/tasks/017_add_cdek_order_creation_endpoint.md +++ b/spec/tasks/017_add_cdek_order_creation_endpoint.md @@ -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 diff --git a/tests/controllers/v1/test_order.py b/tests/controllers/v1/test_order.py new file mode 100644 index 0000000..2e267c9 --- /dev/null +++ b/tests/controllers/v1/test_order.py @@ -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.", + } + } diff --git a/tests/services/test_order.py b/tests/services/test_order.py new file mode 100644 index 0000000..ec8d1ce --- /dev/null +++ b/tests/services/test_order.py @@ -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()))