diff --git a/app/adapters/delivery_providers/base.py b/app/adapters/delivery_providers/base.py index d1ffa88..0e187e0 100644 --- a/app/adapters/delivery_providers/base.py +++ b/app/adapters/delivery_providers/base.py @@ -10,8 +10,8 @@ class DeliveryProvider(ABC): name: str @abstractmethod - async def get_price(self, request: DeliveryRequest) -> DeliveryPrice: - """Fetch one quote from an external provider.""" + async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]: + """Fetch provider tariffs for a delivery request.""" raise NotImplementedError diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py index 4b167d8..b63f1bd 100644 --- a/app/adapters/delivery_providers/cdek/client.py +++ b/app/adapters/delivery_providers/cdek/client.py @@ -89,6 +89,7 @@ class CDEKClient: if not isinstance(raw_payload, dict): raise CDEKClientError("CDEK tariff payload must be a JSON object.") + log.info(f"Founded {len(response.json())} tarrifs") return raw_payload raise CDEKClientError("CDEK tariff request failed unexpectedly.") @@ -201,7 +202,7 @@ class CDEKClient: headers={"Authorization": f"Bearer {token}"}, timeout=self._timeout_seconds, ) - log.info(response.json()) + 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)) @@ -282,7 +283,7 @@ class CDEKProvider(DeliveryProvider): ) return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds) - async def get_price(self, request: DeliveryRequest) -> DeliveryPrice: + async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]: raw_payload = await self._client.get_raw_price(request) return map_cdek_response(raw_payload) diff --git a/app/adapters/delivery_providers/cdek/mapper.py b/app/adapters/delivery_providers/cdek/mapper.py index 8c7ce1e..e3a4e78 100644 --- a/app/adapters/delivery_providers/cdek/mapper.py +++ b/app/adapters/delivery_providers/cdek/mapper.py @@ -10,9 +10,20 @@ class CDEKMappingError(ValueError): """Raised when CDEK response cannot be mapped.""" -def map_cdek_response(payload: dict[str, Any]) -> DeliveryPrice: - tariff = _get_first_tariff(payload) +def map_cdek_response(payload: dict[str, Any]) -> list[DeliveryPrice]: + tariffs = _get_tariffs(payload) + payload_currency = payload.get("currency") + return [ + _map_tariff(tariff, payload_currency=payload_currency) + for tariff in tariffs + ] + +def _map_tariff( + tariff: dict[str, Any], + *, + payload_currency: object, +) -> DeliveryPrice: service_name = tariff.get("tariff_name") or tariff.get("tariff_code") if service_name is None: raise CDEKMappingError("CDEK tariff_name is missing.") @@ -26,7 +37,7 @@ def map_cdek_response(payload: dict[str, Any]) -> DeliveryPrice: raise CDEKMappingError("CDEK period_min is missing.") period_max = tariff.get("period_max", period_min) - raw_currency = tariff.get("currency") or payload.get("currency") or "RUB" + raw_currency = tariff.get("currency") or payload_currency or "RUB" try: return DeliveryPrice( @@ -41,11 +52,13 @@ def map_cdek_response(payload: dict[str, Any]) -> DeliveryPrice: raise CDEKMappingError("CDEK response fields have invalid values.") from exc -def _get_first_tariff(payload: dict[str, Any]) -> dict[str, Any]: +def _get_tariffs(payload: dict[str, Any]) -> list[dict[str, Any]]: tariff_codes = payload.get("tariff_codes") if not isinstance(tariff_codes, list) or not tariff_codes: raise CDEKMappingError("CDEK response must include non-empty tariff_codes.") - tariff = tariff_codes[0] - if not isinstance(tariff, dict): - raise CDEKMappingError("CDEK tariff entry must be an object.") - return tariff + tariffs: list[dict[str, Any]] = [] + for tariff in tariff_codes: + if not isinstance(tariff, dict): + raise CDEKMappingError("CDEK tariff entry must be an object.") + tariffs.append(tariff) + return tariffs diff --git a/app/services/aggregator.py b/app/services/aggregator.py index 40a715f..72d2416 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -83,7 +83,7 @@ class AggregatorService: provider_results = await asyncio.gather( *( - self._get_provider_price( + self._get_provider_prices( provider=provider, request=provider_request, cache_key=self._build_cache_key( @@ -98,8 +98,9 @@ class AggregatorService: successful_results = [ price - for price in provider_results - if isinstance(price, DeliveryPrice) + for provider_prices in provider_results + if isinstance(provider_prices, list) + for price in provider_prices ] provider_errors = [ error @@ -142,26 +143,26 @@ class AggregatorService: from_attributes=True, ) - async def _get_provider_price( + async def _get_provider_prices( self, *, provider: DeliveryProvider, request: DeliveryRequest, cache_key: str, - ) -> DeliveryPrice: - cached_price = await self._get_cached_price(cache_key) - if cached_price is not None: - return cached_price + ) -> list[DeliveryPrice]: + cached_prices = await self._get_cached_prices(cache_key) + if cached_prices is not None: + return cached_prices - fresh_price = await provider.get_price(request) - await self._set_cached_price( + fresh_prices = await provider.get_prices(request) + await self._set_cached_prices( cache_key, - fresh_price, + fresh_prices, ttl=getattr(provider, "cache_ttl_seconds", None), ) - return fresh_price + return fresh_prices - async def _get_cached_price(self, cache_key: str) -> DeliveryPrice | None: + async def _get_cached_prices(self, cache_key: str) -> list[DeliveryPrice] | None: if self._cache is None: return None try: @@ -171,14 +172,14 @@ class AggregatorService: if payload is None: return None try: - return self._coerce_delivery_price(payload) + return self._coerce_delivery_prices(payload) except Exception: return None - async def _set_cached_price( + async def _set_cached_prices( self, cache_key: str, - payload: DeliveryPrice, + payload: list[DeliveryPrice], *, ttl: int | None, ) -> None: @@ -227,3 +228,9 @@ class AggregatorService: @staticmethod def _coerce_delivery_price(value: object) -> DeliveryPrice: return DeliveryPrice.model_validate(value, from_attributes=True) + + @classmethod + def _coerce_delivery_prices(cls, value: object) -> list[DeliveryPrice]: + if not isinstance(value, list): + raise TypeError("Cached delivery prices must be a list.") + return [cls._coerce_delivery_price(item) for item in value] diff --git a/http-client.http b/http-client.http index 4a0e90c..d362a0a 100644 --- a/http-client.http +++ b/http-client.http @@ -71,3 +71,8 @@ Content-Type: application/json > {% client.global.set("order_uuid", response.body.entity.uuid); %} + +### 5. Список доступных тарифов +GET {{base_url}}/v2/calculator/alltariffs +Authorization: Bearer {{auth_token}} +X-User-Lang: rus diff --git a/spec/index.md b/spec/index.md index 778f0ea..727bbe9 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-15T20:06:00+00:00` +> Generated at (UTC): `2026-03-15T21:04:41+00:00` ## Tasks @@ -24,11 +24,11 @@ | 014 | DONE | 2026-03-12 | Add minimal structlog JSON logging | `spec/tasks/014_add_minimal_structlog_json_logging.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 | 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-14 | Add CDEK order creation endpoint | `spec/tasks/018_add_cdek_order_creation_endpoint.md` | ## Summary - Total: **19** -- TODO: **2** -- DONE: **17** +- TODO: **1** +- DONE: **18** diff --git a/spec/tasks/017_return_all_cdek_tariffs.md b/spec/tasks/017_return_all_cdek_tariffs.md index 48eda5a..e28d862 100644 --- a/spec/tasks/017_return_all_cdek_tariffs.md +++ b/spec/tasks/017_return_all_cdek_tariffs.md @@ -1,7 +1,7 @@ --- id: 017 title: Return all tariffs from CDEK price calculation -status: TODO +status: DONE created: 2026-03-14 --- diff --git a/tests/adapters/delivery_providers/cdek/test_client.py b/tests/adapters/delivery_providers/cdek/test_client.py index 8cc9ae8..3098e1d 100644 --- a/tests/adapters/delivery_providers/cdek/test_client.py +++ b/tests/adapters/delivery_providers/cdek/test_client.py @@ -1,4 +1,5 @@ import asyncio +from decimal import Decimal from typing import Any import httpx @@ -134,6 +135,13 @@ class RecordingHTTPClient: "currency": "RUB", "period_min": 1, "period_max": 2, + }, + { + "tariff_name": "Economy", + "delivery_sum": "499.00", + "currency": "RUB", + "period_min": 3, + "period_max": 5, } ] }, @@ -341,13 +349,15 @@ observability: adapter_config=settings.adapter, ) - result = asyncio.run(provider.get_price(_make_request())) + result = asyncio.run(provider.get_prices(_make_request())) assert provider.cache_ttl_seconds == 777 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 result.provider == "cdek" + assert [price.provider for price in result] == ["cdek", "cdek"] + assert [price.service_name for price in result] == ["Express", "Economy"] + assert [price.price for price in result] == [Decimal("899.00"), Decimal("499.00")] def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None: @@ -361,9 +371,10 @@ def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None: adapter_config=adapter_config, ) - asyncio.run(provider.get_price(_make_request())) + result = asyncio.run(provider.get_prices(_make_request())) assert provider.cache_ttl_seconds == 900 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 len(result) == 2 diff --git a/tests/adapters/delivery_providers/cdek/test_mapper.py b/tests/adapters/delivery_providers/cdek/test_mapper.py index c774742..f9d9eb3 100644 --- a/tests/adapters/delivery_providers/cdek/test_mapper.py +++ b/tests/adapters/delivery_providers/cdek/test_mapper.py @@ -5,8 +5,9 @@ import pytest from app.adapters.delivery_providers.cdek.mapper import CDEKMappingError, map_cdek_response -def test_map_cdek_response_maps_first_tariff_to_unified_model() -> None: +def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None: payload = { + "currency": "usd", "tariff_codes": [ { "tariff_name": "Express", @@ -14,18 +15,24 @@ def test_map_cdek_response_maps_first_tariff_to_unified_model() -> None: "currency": "rub", "period_min": 2, "period_max": 4, + }, + { + "tariff_code": 136, + "delivery_sum": "999.00", + "period_min": 5, + "period_max": 7, } ] } result = map_cdek_response(payload) - assert result.provider == "cdek" - assert result.service_name == "Express" - assert result.price == Decimal("1234.50") - assert result.currency == "RUB" - assert result.delivery_days_min == 2 - assert result.delivery_days_max == 4 + assert [price.provider for price in result] == ["cdek", "cdek"] + assert [price.service_name for price in result] == ["Express", "136"] + assert [price.price for price in result] == [Decimal("1234.50"), Decimal("999.00")] + assert [price.currency for price in result] == ["RUB", "USD"] + assert [price.delivery_days_min for price in result] == [2, 5] + assert [price.delivery_days_max for price in result] == [4, 7] def test_map_cdek_response_raises_for_missing_tariff_codes() -> None: diff --git a/tests/controllers/v1/test_delivery.py b/tests/controllers/v1/test_delivery.py index 9ed71fb..98a5ab9 100644 --- a/tests/controllers/v1/test_delivery.py +++ b/tests/controllers/v1/test_delivery.py @@ -64,16 +64,26 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency( def __init__(self) -> None: self.calls: list[DeliveryRequest] = [] - async def get_price(self, request: DeliveryRequest) -> DeliveryPrice: + async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]: self.calls.append(request) - return DeliveryPrice( - provider=self.name, - service_name="stub-service", - price=Decimal("123.50"), - currency="RUB", - delivery_days_min=2, - delivery_days_max=3, - ) + return [ + DeliveryPrice( + provider=self.name, + service_name="slow", + price=Decimal("223.50"), + currency="RUB", + delivery_days_min=4, + delivery_days_max=5, + ), + DeliveryPrice( + provider=self.name, + service_name="fast", + price=Decimal("123.50"), + currency="RUB", + delivery_days_min=2, + delivery_days_max=3, + ), + ] class StubCDEKProvider: @classmethod @@ -135,11 +145,19 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency( assert first_response.json() == [ { "provider": "stub-provider", - "service_name": "stub-service", + "service_name": "fast", "price": "124", "currency": "RUB", "delivery_days_min": 2, "delivery_days_max": 3, + }, + { + "provider": "stub-provider", + "service_name": "slow", + "price": "224", + "currency": "RUB", + "delivery_days_min": 4, + "delivery_days_max": 5, } ] assert second_response.json() == first_response.json() @@ -148,15 +166,25 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency( def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None: - expected_price = DeliveryPrice( - provider="cdek", - service_name="express", - price=Decimal("100.50"), - currency="RUB", - delivery_days_min=1, - delivery_days_max=2, - ) - service = StubAggregatorService(response=[expected_price]) + expected_prices = [ + DeliveryPrice( + provider="cdek", + service_name="economy", + price=Decimal("150.00"), + currency="RUB", + delivery_days_min=3, + delivery_days_max=5, + ), + DeliveryPrice( + provider="cdek", + service_name="express", + price=Decimal("100.50"), + currency="RUB", + delivery_days_min=1, + delivery_days_max=2, + ), + ] + service = StubAggregatorService(response=expected_prices) app = create_app() _install_service_override(app, service) @@ -171,7 +199,7 @@ def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None: response = asyncio.run(run_request()) assert response.status_code == 200 - assert response.json() == [expected_price.model_dump(mode="json")] + assert response.json() == [price.model_dump(mode="json") for price in expected_prices] assert len(service.calls) == 1 assert service.calls[0] == DeliveryRequest( entity=DeliveryEntity.INDIVIDUAL, diff --git a/tests/services/test_aggregator.py b/tests/services/test_aggregator.py index 2987507..fdb7fe4 100644 --- a/tests/services/test_aggregator.py +++ b/tests/services/test_aggregator.py @@ -17,7 +17,7 @@ class StubProvider: self, name: str, *, - response: DeliveryPrice | None = None, + response: list[DeliveryPrice] | None = None, error: Exception | None = None, cache_ttl_seconds: int = 900, ) -> None: @@ -27,7 +27,7 @@ class StubProvider: self.cache_ttl_seconds = cache_ttl_seconds self.calls: list[DeliveryRequest] = [] - async def get_price(self, request: DeliveryRequest) -> DeliveryPrice: + async def get_prices(self, request: DeliveryRequest) -> list[DeliveryPrice]: self.calls.append(request) if self._error is not None: raise self._error @@ -73,10 +73,10 @@ def _make_request(**overrides: object) -> DeliveryRequest: return DeliveryRequest(**payload) -def _make_price(provider: str, price: str) -> DeliveryPrice: +def _make_price(provider: str, price: str, *, service_name: str = "standard") -> DeliveryPrice: return DeliveryPrice( provider=provider, - service_name="standard", + service_name=service_name, price=Decimal(price), currency="RUB", delivery_days_min=2, @@ -85,8 +85,19 @@ def _make_price(provider: str, price: str) -> DeliveryPrice: def test_get_all_prices_full_success_returns_sorted_and_updates_cache() -> None: - provider_a = StubProvider(name="a", response=_make_price("a", "300.49"), cache_ttl_seconds=111) - provider_b = StubProvider(name="b", response=_make_price("b", "100.40"), cache_ttl_seconds=222) + provider_a = StubProvider( + name="a", + response=[ + _make_price("a", "300.49", service_name="economy"), + _make_price("a", "200.49", service_name="express"), + ], + cache_ttl_seconds=111, + ) + provider_b = StubProvider( + name="b", + response=[_make_price("b", "100.40")], + cache_ttl_seconds=222, + ) cache = StubCache() service = AggregatorService( [provider_a, provider_b], @@ -96,16 +107,18 @@ def test_get_all_prices_full_success_returns_sorted_and_updates_cache() -> None: result = asyncio.run(service.get_all_prices(_make_request())) - assert [price.provider for price in result] == ["b", "a"] - assert [price.price for price in result] == [Decimal("110"), Decimal("331")] + assert [price.provider for price in result] == ["b", "a", "a"] + assert [price.service_name for price in result] == ["standard", "express", "economy"] + assert [price.price for price in result] == [Decimal("110"), Decimal("221"), Decimal("331")] assert len(provider_a.calls) == 1 assert len(provider_b.calls) == 1 assert len(cache.get_calls) == 2 + assert [len(payload) for _, payload, _ in cache.set_calls] == [2, 1] assert [ttl for _, _, ttl in cache.set_calls] == [111, 222] def test_get_all_prices_normalizes_and_forwards_country_code_to_providers() -> 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()) result = asyncio.run(service.get_all_prices(_make_request(country_code=" kz "))) @@ -116,13 +129,20 @@ def test_get_all_prices_normalizes_and_forwards_country_code_to_providers() -> N def test_get_all_prices_partial_failure_excludes_failed_provider() -> None: - provider_ok = StubProvider(name="ok", response=_make_price("ok", "150.00")) + provider_ok = StubProvider( + name="ok", + response=[ + _make_price("ok", "150.00", service_name="economy"), + _make_price("ok", "120.00", service_name="express"), + ], + ) provider_failed = StubProvider(name="failed", error=RuntimeError("provider unavailable")) service = AggregatorService([provider_ok, provider_failed], cache=StubCache()) result = asyncio.run(service.get_all_prices(_make_request())) - assert [price.provider for price in result] == ["ok"] + assert [price.provider for price in result] == ["ok", "ok"] + assert [price.service_name for price in result] == ["express", "economy"] assert len(provider_ok.calls) == 1 assert len(provider_failed.calls) == 1 @@ -153,9 +173,12 @@ def test_get_all_prices_raises_invalid_request_for_provider_request_errors() -> def test_get_all_prices_cache_hit_skips_provider_call() -> None: - cached_payload = _make_price("cdek", "100.40").model_dump(mode="json") + cached_payload = [ + _make_price("cdek", "100.40", service_name="express").model_dump(mode="json"), + _make_price("cdek", "200.40", service_name="economy").model_dump(mode="json"), + ] cache = StubCache(forced_get_value=cached_payload) - provider = StubProvider(name="cdek", response=_make_price("cdek", "150.00")) + provider = StubProvider(name="cdek", response=[_make_price("cdek", "150.00")]) service = AggregatorService( [provider], cache=cache, @@ -164,16 +187,23 @@ def test_get_all_prices_cache_hit_skips_provider_call() -> None: result = asyncio.run(service.get_all_prices(_make_request())) - assert [price.provider for price in result] == ["cdek"] - assert result[0].price == Decimal("110") + assert [price.provider for price in result] == ["cdek", "cdek"] + assert [price.service_name for price in result] == ["express", "economy"] + assert [price.price for price in result] == [Decimal("110"), Decimal("220")] assert provider.calls == [] assert len(cache.get_calls) == 1 assert cache.set_calls == [] def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> None: - provider_a = StubProvider(name="a", response=_make_price("a", "300.00")) - provider_b = StubProvider(name="b", response=_make_price("b", "100.00")) + provider_a = StubProvider( + name="a", + response=[ + _make_price("a", "300.00", service_name="economy"), + _make_price("a", "200.00", service_name="express"), + ], + ) + provider_b = StubProvider(name="b", response=[_make_price("b", "100.00")]) delegated_inputs: list[list[DeliveryPrice]] = [] delegated_multipliers: list[Decimal] = [] @@ -192,6 +222,11 @@ def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> Non result = asyncio.run(service.get_all_prices(_make_request())) - assert [price.provider for price in delegated_inputs[0]] == ["a", "b"] + assert [price.provider for price in delegated_inputs[0]] == ["a", "a", "b"] + assert [price.service_name for price in delegated_inputs[0]] == [ + "economy", + "express", + "standard", + ] assert delegated_multipliers == [Decimal("1.23")] assert [price.provider for price in result] == ["a"]