013 add price multiplier

This commit is contained in:
Раис Юсупалиев
2026-03-09 16:14:49 +03:00
parent da301d4bc4
commit 2bd884c8d5
20 changed files with 312 additions and 25 deletions
+2
View File
@@ -1,5 +1,6 @@
"""Application configuration split by architecture component.""" """Application configuration split by architecture component."""
from decimal import Decimal
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
import sys import sys
@@ -29,6 +30,7 @@ class ServiceConfig(BaseModel):
class BusinessLogicConfig(BaseModel): class BusinessLogicConfig(BaseModel):
weight_round_scale: int = 2 weight_round_scale: int = 2
provider_price_multiplier: Decimal = Field(..., gt=0)
class RepositoryConfig(BaseModel): class RepositoryConfig(BaseModel):
+1
View File
@@ -35,6 +35,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
providers=providers, providers=providers,
cache=cache, cache=cache,
weight_round_scale=settings.business_logic.weight_round_scale, weight_round_scale=settings.business_logic.weight_round_scale,
provider_price_multiplier=settings.business_logic.provider_price_multiplier,
) )
return service return service
+53 -6
View File
@@ -6,10 +6,12 @@ from decimal import ROUND_HALF_UP, Decimal
from typing import Protocol from typing import Protocol
DEFAULT_WEIGHT_ROUND_SCALE = 2 DEFAULT_WEIGHT_ROUND_SCALE = 2
DEFAULT_PROVIDER_PRICE_MULTIPLIER = Decimal("1")
MAX_ROUND_SCALE = 6 MAX_ROUND_SCALE = 6
DIMENSIONS_ROUND_SCALE = 1 DIMENSIONS_ROUND_SCALE = 1
MIN_WEIGHT_KG = Decimal("0.01") MIN_WEIGHT_KG = Decimal("0.01")
MIN_DIMENSION_CM = Decimal("0.1") MIN_DIMENSION_CM = Decimal("0.1")
INTEGER_PRICE_QUANTIZER = Decimal("1")
_MISSING = object() _MISSING = object()
@@ -77,12 +79,20 @@ def normalize_delivery_request(
) )
def filter_valid_prices(prices: Iterable[object]) -> list[ProviderPrice]: def filter_valid_prices(
prices: Iterable[object],
*,
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
) -> list[ProviderPrice]:
"""Normalize price records and keep only domain-valid entries.""" """Normalize price records and keep only domain-valid entries."""
normalized_multiplier = _normalize_price_multiplier(price_multiplier)
valid_prices: list[ProviderPrice] = [] valid_prices: list[ProviderPrice] = []
for price_entry in prices: for price_entry in prices:
normalized_price = _normalize_price(price_entry) normalized_price = _normalize_price(
price_entry,
price_multiplier=normalized_multiplier,
)
if normalized_price is not None: if normalized_price is not None:
valid_prices.append(normalized_price) valid_prices.append(normalized_price)
return valid_prices return valid_prices
@@ -94,13 +104,23 @@ def sort_prices_by_price(prices: Iterable[ProviderPrice]) -> list[ProviderPrice]
return sorted(prices, key=lambda price_entry: price_entry.price) return sorted(prices, key=lambda price_entry: price_entry.price)
def filter_and_sort_prices(prices: Iterable[object]) -> list[ProviderPrice]: def filter_and_sort_prices(
prices: Iterable[object],
*,
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
) -> list[ProviderPrice]:
"""Apply domain filtering and return prices ordered by value.""" """Apply domain filtering and return prices ordered by value."""
return sort_prices_by_price(filter_valid_prices(prices)) return sort_prices_by_price(
filter_valid_prices(prices, price_multiplier=price_multiplier)
)
def _normalize_price(candidate: object) -> ProviderPrice | None: def _normalize_price(
candidate: object,
*,
price_multiplier: Decimal,
) -> ProviderPrice | None:
provider = _normalize_text(_get_optional_attr(candidate, "provider")) provider = _normalize_text(_get_optional_attr(candidate, "provider"))
service_name = _normalize_text(_get_optional_attr(candidate, "service_name")) service_name = _normalize_text(_get_optional_attr(candidate, "service_name"))
currency = _normalize_text(_get_optional_attr(candidate, "currency")).upper() currency = _normalize_text(_get_optional_attr(candidate, "currency")).upper()
@@ -117,6 +137,12 @@ def _normalize_price(candidate: object) -> ProviderPrice | None:
price = _try_to_decimal(price_raw) price = _try_to_decimal(price_raw)
if price is None or not price.is_finite() or price <= 0: if price is None or not price.is_finite() or price <= 0:
return None return None
adjusted_price = _apply_price_multiplier_and_round(
price,
price_multiplier=price_multiplier,
)
if adjusted_price is None:
return None
min_days = _try_to_int(min_days_raw) min_days = _try_to_int(min_days_raw)
max_days = _try_to_int(max_days_raw) max_days = _try_to_int(max_days_raw)
@@ -129,7 +155,7 @@ def _normalize_price(candidate: object) -> ProviderPrice | None:
return ProviderPrice( return ProviderPrice(
provider=provider, provider=provider,
service_name=service_name, service_name=service_name,
price=price, price=adjusted_price,
currency=currency, currency=currency,
delivery_days_min=min_days, delivery_days_min=min_days,
delivery_days_max=max_days, delivery_days_max=max_days,
@@ -192,6 +218,27 @@ def _round_half_up(value: Decimal, scale: int) -> Decimal:
return value.quantize(quantizer, rounding=ROUND_HALF_UP) return value.quantize(quantizer, rounding=ROUND_HALF_UP)
def _normalize_price_multiplier(value: Decimal) -> Decimal:
multiplier = _try_to_decimal(value)
if multiplier is None or not multiplier.is_finite() or multiplier <= 0:
raise ValueError(f"Price multiplier must be a positive finite Decimal: {value!r}")
return multiplier
def _apply_price_multiplier_and_round(
price: Decimal,
*,
price_multiplier: Decimal,
) -> Decimal | None:
adjusted_price = (price * price_multiplier).quantize(
INTEGER_PRICE_QUANTIZER,
rounding=ROUND_HALF_UP,
)
if not adjusted_price.is_finite() or adjusted_price <= 0:
return None
return adjusted_price
def _to_decimal(value: object) -> Decimal: def _to_decimal(value: object) -> Decimal:
converted = _try_to_decimal(value) converted = _try_to_decimal(value)
if converted is None: if converted is None:
+19 -5
View File
@@ -3,11 +3,13 @@
import asyncio import asyncio
import hashlib import hashlib
import json import json
from collections.abc import Callable, Iterable, Sequence from collections.abc import Iterable, Sequence
from decimal import Decimal
from typing import Protocol from typing import Protocol
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
from app.domain.price import ( from app.domain.price import (
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
DEFAULT_WEIGHT_ROUND_SCALE, DEFAULT_WEIGHT_ROUND_SCALE,
NormalizedDeliveryRequest, NormalizedDeliveryRequest,
filter_and_sort_prices, filter_and_sort_prices,
@@ -31,6 +33,15 @@ class PriceCacheProtocol(Protocol):
async def set(self, key: str, value: object, ttl: int | None = None) -> None: ... async def set(self, key: str, value: object, ttl: int | None = None) -> None: ...
class FilterAndSortPricesFn(Protocol):
def __call__(
self,
prices: Iterable[object],
*,
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
) -> list[object]: ...
class AggregatorService: class AggregatorService:
def __init__( def __init__(
self, self,
@@ -38,13 +49,13 @@ class AggregatorService:
cache: PriceCacheProtocol | None = None, cache: PriceCacheProtocol | None = None,
*, *,
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE, weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
filter_and_sort_prices_fn: Callable[[Iterable[object]], list[object]] = ( provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
filter_and_sort_prices filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
),
) -> None: ) -> None:
self._providers = tuple(providers) self._providers = tuple(providers)
self._cache = cache self._cache = cache
self._weight_round_scale = weight_round_scale self._weight_round_scale = weight_round_scale
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: DeliveryRequest) -> list[DeliveryPrice]:
@@ -86,7 +97,10 @@ class AggregatorService:
raise InvalidDeliveryRequestError( raise InvalidDeliveryRequestError(
"Delivery request is invalid for configured providers." "Delivery request is invalid for configured providers."
) )
filtered_and_sorted = self._filter_and_sort_prices(successful_results) filtered_and_sorted = self._filter_and_sort_prices(
successful_results,
price_multiplier=self._provider_price_multiplier,
)
return [self._coerce_delivery_price(price) for price in filtered_and_sorted] return [self._coerce_delivery_price(price) for price in filtered_and_sorted]
async def _get_provider_price( async def _get_provider_price(
+1
View File
@@ -8,6 +8,7 @@ service:
business_logic: business_logic:
weight_round_scale: 2 weight_round_scale: 2
provider_price_multiplier: 1.0
repository: repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
+1
View File
@@ -8,6 +8,7 @@ service:
business_logic: business_logic:
weight_round_scale: 2 weight_round_scale: 2
provider_price_multiplier: 1.0
repository: repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
+4 -3
View File
@@ -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-08T19:58:23+00:00` > Generated at (UTC): `2026-03-09T13:13:56+00:00`
## Tasks ## Tasks
@@ -20,9 +20,10 @@
| 010 | DONE | 2026-03-08 | Migrate to YAML-only configuration loading | `spec/tasks/010_migrate_to_yaml_only_configuration.md` | | 010 | DONE | 2026-03-08 | Migrate to YAML-only configuration loading | `spec/tasks/010_migrate_to_yaml_only_configuration.md` |
| 011 | DONE | 2026-03-08 | Migrate Redis repository client to aioredis | `spec/tasks/011_migrate_redis_repository_to_aioredis.md` | | 011 | DONE | 2026-03-08 | Migrate Redis repository client to aioredis | `spec/tasks/011_migrate_redis_repository_to_aioredis.md` |
| 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` |
## Summary ## Summary
- Total: **13** - Total: **14**
- TODO: **0** - TODO: **0**
- DONE: **13** - DONE: **14**
+3
View File
@@ -20,6 +20,7 @@
- Возвращать унифицированный список тарифов, отсортированных по цене - Возвращать унифицированный список тарифов, отсортированных по цене
- Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком - Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком
- Кешировать ответы провайдеров для исключения повторных внешних запросов. Время кэширования вынести в конфиг - Кешировать ответы провайдеров для исключения повторных внешних запросов. Время кэширования вынести в конфиг
- Применять к цене, полученной от провайдера, конфигурируемый мультипликатор и округлять итоговую цену до целого значения
--- ---
@@ -54,6 +55,8 @@
### Business Logic (`app/domain/`) ### Business Logic (`app/domain/`)
- Правила сортировки и фильтрации тарифов - Правила сортировки и фильтрации тарифов
- Логика сравнения цен - Логика сравнения цен
- Правила применения конфигурируемого мультипликатора к ценам провайдеров
- Округление цены после применения мультипликатора до целого значения по детерминированному правилу
- Нормализация входных данных (например, правила округления веса) - Нормализация входных данных (например, правила округления веса)
- Чистые функции, без IO, без зависимостей от фреймворков - Чистые функции, без IO, без зависимостей от фреймворков
@@ -0,0 +1,47 @@
---
id: 013
title: Add configurable provider price multiplier in domain logic
status: DONE
created: 2026-03-09
---
## Context
Сейчас `app/domain/price.py` валидирует и сортирует тарифы провайдеров без дополнительной постобработки цены. Появилось новое требование: перед возвратом результата применять к цене провайдера конфигурируемый multiplier из YAML-конфига и округлять итог до целого числа.
## Goal
Добавить в Business Logic детерминированное правило пересчёта `DeliveryPrice.price` через multiplier из секции `business_logic` и провести это значение через конфигурацию и service orchestration без переноса формулы в Service или Adapter.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Формула умножения и округления должна жить только в `app/domain/`; Service может только передавать нужный параметр и делегировать вызов.
- Значение multiplier должно читаться из YAML-конфига через секцию `business_logic`.
- После применения multiplier цена должна оставаться `Decimal`, но иметь целочисленное значение.
- Правило округления должно быть явным и детерминированным: до `Decimal("1")` c `ROUND_HALF_UP`.
- Не изменять provider adapters, внешние HTTP-контракты и формат ответа кроме нового значения `price`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В `business_logic` configuration section добавлено обязательное поле для multiplier цены провайдера с валидацией положительного значения.
- Domain functions применяют multiplier к каждой валидной цене провайдера до сортировки результата.
- Итоговая цена после умножения округляется до целого значения по правилу `ROUND_HALF_UP`.
- Service не содержит формулу расчёта и по-прежнему делегирует обработку цен в domain logic.
- Поведение одинаково для свежих ответов провайдера и для cache hit: в обоих случаях наружу возвращается уже пересчитанная цена.
## Definition of Done
- [ ] Обновлена конфигурация `business_logic` и YAML fixtures/example configs для multiplier.
- [ ] Реализована pure domain logic для пересчёта и округления цены.
- [ ] Service wiring передаёт multiplier в domain logic без добавления business rules в Service.
- [ ] Unit/service/config tests покрывают стандартные и edge-case сценарии.
## Tests
- Обновить `tests/domain/test_price.py` для проверки multiplier, округления `ROUND_HALF_UP`, пустого input и невалидных цен.
- Обновить `tests/services/test_aggregator.py` для проверки делегирования multiplier и одинакового поведения fresh/cache hit.
- Обновить `tests/config/test_config_sections.py` для проверки чтения multiplier из YAML и валидации невалидных значений.
- При необходимости обновить `tests/smoke/test_app_import.py` под обязательное новое поле конфигурации.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -10,6 +10,7 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider, CDEKProvider,
CDEKRequestError, CDEKRequestError,
) )
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 DeliveryEntity, DeliveryRequest
@@ -299,6 +300,22 @@ def test_provider_uses_adapter_yaml_config_for_timeout_and_cache_ttl(
config_file = tmp_path / "config.yaml" config_file = tmp_path / "config.yaml"
config_file.write_text( config_file.write_text(
""" """
controller:
api_prefix: "/api/v1"
request_id_header: "X-Request-ID"
service:
provider_timeout_seconds: 10.0
max_parallel_providers: 8
business_logic:
weight_round_scale: 2
provider_price_multiplier: 1.0
repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
adapter: adapter:
cdek_base_url: "https://api.cdek.test/v2" cdek_base_url: "https://api.cdek.test/v2"
cdek_client_id: "yaml-id" cdek_client_id: "yaml-id"
@@ -310,7 +327,7 @@ adapter:
""".strip(), """.strip(),
encoding="utf-8", encoding="utf-8",
) )
monkeypatch.setenv("G2S_CONFIG_FILE", str(config_file)) monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file))
settings = Settings() settings = Settings()
http_client = RecordingHTTPClient() http_client = RecordingHTTPClient()
provider = CDEKProvider.from_adapter_config( provider = CDEKProvider.from_adapter_config(
@@ -6,6 +6,7 @@ service:
business_logic: business_logic:
weight_round_scale: 2 weight_round_scale: 2
provider_price_multiplier: 1.0
repository: repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
@@ -0,0 +1,24 @@
controller:
api_prefix: "/api/v1"
request_id_header: "X-Request-ID"
service:
provider_timeout_seconds: 10.0
max_parallel_providers: 8
business_logic:
weight_round_scale: 2
provider_price_multiplier: 0
repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
adapter:
cdek_base_url: "https://api.cdek.ru/v2"
cdek_client_id: "test-client-id"
cdek_client_secret: "test-client-secret"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
@@ -6,6 +6,7 @@ service:
business_logic: business_logic:
weight_round_scale: 2 weight_round_scale: 2
provider_price_multiplier: 1.0
repository: repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
@@ -0,0 +1,23 @@
controller:
api_prefix: "/api/v1"
request_id_header: "X-Request-ID"
service:
provider_timeout_seconds: 10.0
max_parallel_providers: 8
business_logic:
weight_round_scale: 2
repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
adapter:
cdek_base_url: "https://api.cdek.ru/v2"
cdek_client_id: "test-client-id"
cdek_client_secret: "test-client-secret"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
@@ -6,6 +6,7 @@ service:
business_logic: business_logic:
weight_round_scale: 2 weight_round_scale: 2
provider_price_multiplier: 1.25
repository: repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
+42
View File
@@ -1,3 +1,4 @@
from decimal import Decimal
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -17,6 +18,12 @@ DEFAULT_CONFIG_FILE_FIXTURE = (
TEST_OVERRIDE_CONFIG_FILE_FIXTURE = ( TEST_OVERRIDE_CONFIG_FILE_FIXTURE = (
PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.test.override.yaml" PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.test.override.yaml"
) )
INVALID_PRICE_MULTIPLIER_CONFIG_FILE = (
PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.invalid_price_multiplier.yaml"
)
MISSING_PRICE_MULTIPLIER_CONFIG_FILE = (
PROJECT_ROOT / "tests" / "config" / "fixtures" / "config.missing_price_multiplier.yaml"
)
def _use_runtime_config_files( def _use_runtime_config_files(
@@ -47,6 +54,7 @@ def test_configuration_sections_are_loaded_from_yaml_file(
assert settings.service.provider_timeout_seconds == 10.0 assert settings.service.provider_timeout_seconds == 10.0
assert settings.service.max_parallel_providers == 8 assert settings.service.max_parallel_providers == 8
assert settings.business_logic.weight_round_scale == 2 assert settings.business_logic.weight_round_scale == 2
assert settings.business_logic.provider_price_multiplier == Decimal("1.0")
assert settings.repository.redis_dsn == "redis://localhost:6379/0" assert settings.repository.redis_dsn == "redis://localhost:6379/0"
assert settings.repository.price_cache_ttl_seconds == 900 assert settings.repository.price_cache_ttl_seconds == 900
assert settings.adapter.cdek_base_url == "https://api.cdek.ru/v2" assert settings.adapter.cdek_base_url == "https://api.cdek.ru/v2"
@@ -82,6 +90,7 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) -
assert first is second assert first is second
assert first.service.provider_timeout_seconds == 10.0 assert first.service.provider_timeout_seconds == 10.0
assert first.business_logic.provider_price_multiplier == Decimal("1.0")
get_settings.cache_clear() get_settings.cache_clear()
@@ -98,5 +107,38 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
assert settings.service.provider_timeout_seconds == 17.0 assert settings.service.provider_timeout_seconds == 17.0
assert settings.controller.api_prefix == "/from-config-test-yaml" assert settings.controller.api_prefix == "/from-config-test-yaml"
assert settings.business_logic.provider_price_multiplier == Decimal("1.25")
assert settings.adapter.cdek_client_id == "test-id" assert settings.adapter.cdek_client_id == "test-id"
get_settings.cache_clear() get_settings.cache_clear()
def test_get_settings_fails_when_provider_price_multiplier_is_invalid(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_runtime_config_files(
monkeypatch,
test_config_file=INVALID_PRICE_MULTIPLIER_CONFIG_FILE,
)
with pytest.raises(ValidationError) as error:
get_settings()
locations = {tuple(item["loc"]) for item in error.value.errors()}
assert ("business_logic", "provider_price_multiplier") in locations
get_settings.cache_clear()
def test_get_settings_fails_when_provider_price_multiplier_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_runtime_config_files(
monkeypatch,
test_config_file=MISSING_PRICE_MULTIPLIER_CONFIG_FILE,
)
with pytest.raises(ValidationError) as error:
get_settings()
locations = {tuple(item["loc"]) for item in error.value.errors()}
assert ("business_logic", "provider_price_multiplier") in locations
get_settings.cache_clear()
+2 -2
View File
@@ -69,7 +69,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
return DeliveryPrice( return DeliveryPrice(
provider=self.name, provider=self.name,
service_name="stub-service", service_name="stub-service",
price=Decimal("123.45"), price=Decimal("123.50"),
currency="RUB", currency="RUB",
delivery_days_min=2, delivery_days_min=2,
delivery_days_max=3, delivery_days_max=3,
@@ -136,7 +136,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
{ {
"provider": "stub-provider", "provider": "stub-provider",
"service_name": "stub-service", "service_name": "stub-service",
"price": "123.45", "price": "124",
"currency": "RUB", "currency": "RUB",
"delivery_days_min": 2, "delivery_days_min": 2,
"delivery_days_max": 3, "delivery_days_max": 3,
+28 -1
View File
@@ -80,6 +80,18 @@ def test_filter_valid_prices_handles_empty_input() -> None:
assert filter_and_sort_prices([]) == [] assert filter_and_sort_prices([]) == []
def test_filter_valid_prices_applies_multiplier_and_rounds_half_up() -> None:
low = _make_price(provider="cdek", price=Decimal("100.40"))
high = _make_price(provider="boxberry", price=Decimal("100.50"))
result = filter_valid_prices(
[low, high],
price_multiplier=Decimal("1.1"),
)
assert [price.price for price in result] == [Decimal("110"), Decimal("111")]
def test_filter_valid_prices_excludes_invalid_records() -> None: def test_filter_valid_prices_excludes_invalid_records() -> None:
valid = _make_price(provider=" cdek ", service_name=" express ", currency="rub") valid = _make_price(provider=" cdek ", service_name=" express ", currency="rub")
invalid_provider = _make_price(provider=" ") invalid_provider = _make_price(provider=" ")
@@ -128,6 +140,17 @@ def test_filter_valid_prices_excludes_none_provider_and_service() -> None:
assert result[0].service_name == "express" assert result[0].service_name == "express"
def test_filter_valid_prices_excludes_prices_rounded_to_zero_after_multiplier() -> None:
low_price = _make_price(provider="cdek", price=Decimal("0.40"))
result = filter_valid_prices(
[low_price],
price_multiplier=Decimal("0.5"),
)
assert result == []
def test_sort_prices_by_price_keeps_equal_price_order_stable() -> None: def test_sort_prices_by_price_keeps_equal_price_order_stable() -> None:
price_a = _make_price(provider="a", price=Decimal("150.00")) price_a = _make_price(provider="a", price=Decimal("150.00"))
price_b = _make_price(provider="b", price=Decimal("150.00")) price_b = _make_price(provider="b", price=Decimal("150.00"))
@@ -143,6 +166,10 @@ def test_filter_and_sort_prices_combines_domain_steps() -> None:
price_b = _make_price(provider="b", price=Decimal("100.00")) price_b = _make_price(provider="b", price=Decimal("100.00"))
invalid = _make_price(provider="", price=Decimal("50.00")) invalid = _make_price(provider="", price=Decimal("50.00"))
result = filter_and_sort_prices([price_a, invalid, price_b]) result = filter_and_sort_prices(
[price_a, invalid, price_b],
price_multiplier=Decimal("1.3"),
)
assert [price.provider for price in result] == ["b", "a"] assert [price.provider for price in result] == ["b", "a"]
assert [price.price for price in result] == [Decimal("130"), Decimal("286")]
+21
View File
@@ -154,9 +154,30 @@ def test_repository_yaml_config_sets_redis_connection_and_ttl(
config_file = tmp_path / "config.yaml" config_file = tmp_path / "config.yaml"
config_file.write_text( config_file.write_text(
""" """
controller:
api_prefix: "/api/v1"
request_id_header: "X-Request-ID"
service:
provider_timeout_seconds: 10.0
max_parallel_providers: 8
business_logic:
weight_round_scale: 2
provider_price_multiplier: 1.0
repository: repository:
redis_dsn: "redis://redis.internal:6380/5" redis_dsn: "redis://redis.internal:6380/5"
price_cache_ttl_seconds: 123 price_cache_ttl_seconds: 123
adapter:
cdek_base_url: "https://api.cdek.ru/v2"
cdek_client_id: "test-client-id"
cdek_client_secret: "test-client-secret"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
""".strip(), """.strip(),
encoding="utf-8", encoding="utf-8",
) )
+20 -7
View File
@@ -85,14 +85,19 @@ def _make_price(provider: str, price: str) -> DeliveryPrice:
def test_get_all_prices_full_success_returns_sorted_and_updates_cache() -> None: def test_get_all_prices_full_success_returns_sorted_and_updates_cache() -> None:
provider_a = StubProvider(name="a", response=_make_price("a", "300.00"), cache_ttl_seconds=111) 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.00"), cache_ttl_seconds=222) provider_b = StubProvider(name="b", response=_make_price("b", "100.40"), cache_ttl_seconds=222)
cache = StubCache() cache = StubCache()
service = AggregatorService([provider_a, provider_b], cache=cache) service = AggregatorService(
[provider_a, provider_b],
cache=cache,
provider_price_multiplier=Decimal("1.1"),
)
result = asyncio.run(service.get_all_prices(_make_request())) result = asyncio.run(service.get_all_prices(_make_request()))
assert [price.provider for price in result] == ["b", "a"] assert [price.provider for price in result] == ["b", "a"]
assert [price.price for price in result] == [Decimal("110"), Decimal("331")]
assert len(provider_a.calls) == 1 assert len(provider_a.calls) == 1
assert len(provider_b.calls) == 1 assert len(provider_b.calls) == 1
assert len(cache.get_calls) == 2 assert len(cache.get_calls) == 2
@@ -148,15 +153,19 @@ def test_get_all_prices_raises_invalid_request_for_provider_request_errors() ->
def test_get_all_prices_cache_hit_skips_provider_call() -> None: def test_get_all_prices_cache_hit_skips_provider_call() -> None:
cached_payload = _make_price("cdek", "99.00").model_dump(mode="json") cached_payload = _make_price("cdek", "100.40").model_dump(mode="json")
cache = StubCache(forced_get_value=cached_payload) 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) service = AggregatorService(
[provider],
cache=cache,
provider_price_multiplier=Decimal("1.1"),
)
result = asyncio.run(service.get_all_prices(_make_request())) result = asyncio.run(service.get_all_prices(_make_request()))
assert [price.provider for price in result] == ["cdek"] assert [price.provider for price in result] == ["cdek"]
assert result[0].price == Decimal("99.00") assert result[0].price == Decimal("110")
assert provider.calls == [] assert provider.calls == []
assert len(cache.get_calls) == 1 assert len(cache.get_calls) == 1
assert cache.set_calls == [] assert cache.set_calls == []
@@ -166,19 +175,23 @@ def test_get_all_prices_delegates_filtering_and_sorting_to_domain_logic() -> Non
provider_a = StubProvider(name="a", response=_make_price("a", "300.00")) provider_a = StubProvider(name="a", response=_make_price("a", "300.00"))
provider_b = StubProvider(name="b", response=_make_price("b", "100.00")) provider_b = StubProvider(name="b", response=_make_price("b", "100.00"))
delegated_inputs: list[list[DeliveryPrice]] = [] delegated_inputs: list[list[DeliveryPrice]] = []
delegated_multipliers: list[Decimal] = []
def fake_filter_and_sort(prices): def fake_filter_and_sort(prices, *, price_multiplier):
price_list = list(prices) price_list = list(prices)
delegated_inputs.append(price_list) delegated_inputs.append(price_list)
delegated_multipliers.append(price_multiplier)
return [price_list[0]] return [price_list[0]]
service = AggregatorService( service = AggregatorService(
[provider_a, provider_b], [provider_a, provider_b],
cache=StubCache(), cache=StubCache(),
provider_price_multiplier=Decimal("1.23"),
filter_and_sort_prices_fn=fake_filter_and_sort, filter_and_sort_prices_fn=fake_filter_and_sort,
) )
result = asyncio.run(service.get_all_prices(_make_request())) 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", "b"]
assert delegated_multipliers == [Decimal("1.23")]
assert [price.provider for price in result] == ["a"] assert [price.provider for price in result] == ["a"]