diff --git a/app/domain/price.py b/app/domain/price.py new file mode 100644 index 0000000..0a0b708 --- /dev/null +++ b/app/domain/price.py @@ -0,0 +1,214 @@ +"""Pure business rules for request normalization and price ordering.""" + +from collections.abc import Iterable +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal +from typing import Protocol + +DEFAULT_WEIGHT_ROUND_SCALE = 2 +MAX_ROUND_SCALE = 6 +DIMENSIONS_ROUND_SCALE = 1 +MIN_WEIGHT_KG = Decimal("0.01") +MIN_DIMENSION_CM = Decimal("0.1") + +_MISSING = object() + + +class RequestLike(Protocol): + entity: object + from_city: object + to_city: object + weight_kg: object + length_cm: object + width_cm: object + height_cm: object + + +@dataclass(frozen=True, slots=True) +class NormalizedDeliveryRequest: + entity: str + from_city: str + to_city: str + weight_kg: Decimal + length_cm: Decimal + width_cm: Decimal + height_cm: Decimal + + +@dataclass(frozen=True, slots=True) +class ProviderPrice: + provider: str + service_name: str + price: Decimal + currency: str + delivery_days_min: int + delivery_days_max: int + + +def normalize_delivery_request( + request: RequestLike, + *, + weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE, +) -> NormalizedDeliveryRequest: + """Return normalized request values according to domain rules.""" + + normalized_weight_scale = _clamp_scale(weight_round_scale) + + return NormalizedDeliveryRequest( + entity=str(_get_required_attr(request, "entity")), + from_city=_normalize_text(_get_required_attr(request, "from_city")), + to_city=_normalize_text(_get_required_attr(request, "to_city")), + weight_kg=_normalize_weight( + _to_decimal(_get_required_attr(request, "weight_kg")), + normalized_weight_scale, + ), + length_cm=_normalize_dimension( + _to_decimal(_get_required_attr(request, "length_cm")), + ), + width_cm=_normalize_dimension( + _to_decimal(_get_required_attr(request, "width_cm")), + ), + height_cm=_normalize_dimension( + _to_decimal(_get_required_attr(request, "height_cm")), + ), + ) + + +def filter_valid_prices(prices: Iterable[object]) -> list[ProviderPrice]: + """Normalize price records and keep only domain-valid entries.""" + + valid_prices: list[ProviderPrice] = [] + for price_entry in prices: + normalized_price = _normalize_price(price_entry) + if normalized_price is not None: + valid_prices.append(normalized_price) + return valid_prices + + +def sort_prices_by_price(prices: Iterable[ProviderPrice]) -> list[ProviderPrice]: + """Sort provider prices by ascending value.""" + + return sorted(prices, key=lambda price_entry: price_entry.price) + + +def filter_and_sort_prices(prices: Iterable[object]) -> list[ProviderPrice]: + """Apply domain filtering and return prices ordered by value.""" + + return sort_prices_by_price(filter_valid_prices(prices)) + + +def _normalize_price(candidate: object) -> ProviderPrice | None: + provider = _normalize_text(_get_optional_attr(candidate, "provider")) + service_name = _normalize_text(_get_optional_attr(candidate, "service_name")) + currency = _normalize_text(_get_optional_attr(candidate, "currency")).upper() + price_raw = _get_optional_attr(candidate, "price") + min_days_raw = _get_optional_attr(candidate, "delivery_days_min") + max_days_raw = _get_optional_attr(candidate, "delivery_days_max") + + if not provider or not service_name: + return None + + if len(currency) != 3 or not currency.isalpha(): + return None + + price = _try_to_decimal(price_raw) + if price is None or not price.is_finite() or price <= 0: + return None + + min_days = _try_to_int(min_days_raw) + max_days = _try_to_int(max_days_raw) + if min_days is None or max_days is None: + return None + + if min_days < 0 or max_days < min_days: + return None + + return ProviderPrice( + provider=provider, + service_name=service_name, + price=price, + currency=currency, + delivery_days_min=min_days, + delivery_days_max=max_days, + ) + + +def _normalize_weight(weight: Decimal, scale: int) -> Decimal: + rounded = _round_half_up(weight, scale) + if rounded < MIN_WEIGHT_KG: + return MIN_WEIGHT_KG + return rounded + + +def _normalize_dimension(value: Decimal) -> Decimal: + rounded = _round_half_up(value, DIMENSIONS_ROUND_SCALE) + if rounded < MIN_DIMENSION_CM: + return MIN_DIMENSION_CM + return rounded + + +def _get_required_attr(source: object, name: str) -> object: + value = _get_optional_attr(source, name) + if value is _MISSING: + raise ValueError(f"Missing required attribute: {name}") + return value + + +def _get_optional_attr(source: object, name: str) -> object: + try: + return getattr(source, name) + except AttributeError: + return _MISSING + + +def _normalize_text(value: object) -> str: + if value is _MISSING or value is None: + return "" + return str(value).strip() + + +def _clamp_scale(scale: int) -> int: + if scale < 0: + return 0 + if scale > MAX_ROUND_SCALE: + return MAX_ROUND_SCALE + return scale + + +def _round_half_up(value: Decimal, scale: int) -> Decimal: + quantizer = Decimal("1").scaleb(-scale) + return value.quantize(quantizer, rounding=ROUND_HALF_UP) + + +def _to_decimal(value: object) -> Decimal: + converted = _try_to_decimal(value) + if converted is None: + raise ValueError(f"Value cannot be converted to Decimal: {value!r}") + return converted + + +def _try_to_decimal(value: object) -> Decimal | None: + if isinstance(value, Decimal): + return value + if value is _MISSING: + return None + try: + return Decimal(str(value)) + except Exception: + return None + + +def _try_to_int(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if value is _MISSING: + return None + try: + converted = int(value) + except Exception: + return None + if Decimal(str(value)) != Decimal(converted): + return None + return converted diff --git a/app/domain/quotes.py b/app/domain/quotes.py deleted file mode 100644 index db5791f..0000000 --- a/app/domain/quotes.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Business rules for delivery quotes. - -Rules are implemented in task 002. -""" diff --git a/spec/index.md b/spec/index.md index f9c9f22..a2e10cf 100644 --- a/spec/index.md +++ b/spec/index.md @@ -1,25 +1,25 @@ # Spec Tasks Index > ⚠️ This file is generated. Do not edit manually. -> Generated at (UTC): `2026-03-07T09:07:45+00:00` +> Generated at (UTC): `2026-03-07T11:09:24+00:00` ## Tasks -| ID | Status | Created | Title | File | -|---:|:------:|:-------:|:------------------------------------------------|:-----| -| 000 | DONE | 2026-02-01 | Task format reference | `spec/tasks/000_template.md` | -| 001 | TODO | 2026-03-07 | Create app skeleton and component configuration | `spec/tasks/001_create_app_skeleton_and_config.md` | -| 002 | TODO | 2026-03-07 | Implement pure domain price rules | `spec/tasks/002_implement_pure_domain_quote_rules.md` | -| 003 | TODO | 2026-03-07 | Add CDEK provider adapter | `spec/tasks/003_add_cdek_provider_adapter.md` | -| 004 | TODO | 2026-03-07 | Add Redis price cache repository | `spec/tasks/004_add_redis_price_cache_repository.md` | -| 005 | TODO | 2026-03-07 | Implement aggregator service workflow | `spec/tasks/005_implement_aggregator_service_workflow.md` | -| 006 | TODO | 2026-03-07 | Add delivery price controller endpoint | `spec/tasks/006_add_delivery_price_controller.md` | -| 007 | TODO | 2026-03-07 | Add observability correlation and telemetry | `spec/tasks/007_add_observability_correlation.md` | -| 008 | TODO | 2026-03-07 | Add SigNoz to Telegram alerting configuration | `spec/tasks/008_add_signoz_telegram_alerting.md` | -| 009 | TODO | 2026-03-07 | Add local infrastructure stack | `spec/tasks/009_add_local_infra_stack.md` | +| ID | Status | Created | Title | File | +|---:|:------:|:-------:|:------|:-----| +| 000 | DONE | 2026-02-01 | Task format reference | `spec/tasks/000_template.md` | +| 001 | DONE | 2026-03-07 | Create app skeleton and component configuration | `spec/tasks/001_create_app_skeleton_and_config.md` | +| 002 | DONE | 2026-03-07 | Implement pure domain price rules | `spec/tasks/002_implement_pure_domain_quote_rules.md` | +| 003 | TODO | 2026-03-07 | Add CDEK provider adapter | `spec/tasks/003_add_cdek_provider_adapter.md` | +| 004 | TODO | 2026-03-07 | Add Redis price cache repository | `spec/tasks/004_add_redis_price_cache_repository.md` | +| 005 | TODO | 2026-03-07 | Implement aggregator service workflow | `spec/tasks/005_implement_aggregator_service_workflow.md` | +| 006 | TODO | 2026-03-07 | Add delivery price controller endpoint | `spec/tasks/006_add_delivery_price_controller.md` | +| 007 | TODO | 2026-03-07 | Add observability correlation and telemetry | `spec/tasks/007_add_observability_correlation.md` | +| 008 | TODO | 2026-03-07 | Add SigNoz to Telegram alerting configuration | `spec/tasks/008_add_signoz_telegram_alerting.md` | +| 009 | TODO | 2026-03-07 | Add local infrastructure stack | `spec/tasks/009_add_local_infra_stack.md` | ## Summary - Total: **10** -- TODO: **9** -- DONE: **1** +- TODO: **7** +- DONE: **3** diff --git a/spec/tasks/002_implement_pure_domain_quote_rules.md b/spec/tasks/002_implement_pure_domain_quote_rules.md index ea98125..0c173f6 100644 --- a/spec/tasks/002_implement_pure_domain_quote_rules.md +++ b/spec/tasks/002_implement_pure_domain_quote_rules.md @@ -1,7 +1,7 @@ --- id: 002 title: Implement pure domain price rules -status: TODO +status: DONE created: 2026-03-07 --- diff --git a/tests/domain/test_price.py b/tests/domain/test_price.py new file mode 100644 index 0000000..c552ca0 --- /dev/null +++ b/tests/domain/test_price.py @@ -0,0 +1,143 @@ +from decimal import Decimal + +from app.domain.price import ( + filter_and_sort_prices, + filter_valid_prices, + normalize_delivery_request, + sort_prices_by_price, +) +from app.schemas.request import DeliveryEntity, DeliveryRequest +from app.schemas.response import DeliveryPrice + + +def _make_price(**overrides) -> DeliveryPrice: + payload = { + "provider": "provider-a", + "service_name": "standard", + "price": Decimal("100.00"), + "currency": "RUB", + "delivery_days_min": 2, + "delivery_days_max": 4, + } + payload.update(overrides) + return DeliveryPrice.model_construct(**payload) + + +def _make_request(**overrides) -> DeliveryRequest: + payload = { + "entity": DeliveryEntity.INDIVIDUAL, + "from_city": " Moscow ", + "to_city": " Saint Petersburg ", + "weight_kg": 1.235, + "length_cm": 10.04, + "width_cm": 20.05, + "height_cm": 30.06, + } + payload.update(overrides) + return DeliveryRequest(**payload) + + +def test_normalize_delivery_request_applies_rules_without_side_effects() -> None: + request = _make_request() + + normalized = normalize_delivery_request(request, weight_round_scale=2) + + assert normalized is not request + assert normalized.from_city == "Moscow" + assert normalized.to_city == "Saint Petersburg" + assert normalized.weight_kg == Decimal("1.24") + assert normalized.length_cm == Decimal("10.0") + assert normalized.width_cm == Decimal("20.1") + assert normalized.height_cm == Decimal("30.1") + assert request.from_city == " Moscow " + + +def test_normalize_delivery_request_handles_normalization_boundaries() -> None: + request = _make_request( + weight_kg=0.0001, + length_cm=0.01, + width_cm=0.04, + height_cm=0.15, + ) + + low_scale = normalize_delivery_request(request, weight_round_scale=-5) + high_scale = normalize_delivery_request(request, weight_round_scale=99) + + assert low_scale.weight_kg == Decimal("0.01") + assert low_scale.length_cm == Decimal("0.1") + assert low_scale.width_cm == Decimal("0.1") + assert low_scale.height_cm == Decimal("0.2") + assert high_scale.weight_kg == Decimal("0.01") + + +def test_filter_valid_prices_handles_empty_input() -> None: + assert filter_valid_prices([]) == [] + assert filter_and_sort_prices([]) == [] + + +def test_filter_valid_prices_excludes_invalid_records() -> None: + valid = _make_price(provider=" cdek ", service_name=" express ", currency="rub") + invalid_provider = _make_price(provider=" ") + invalid_price = _make_price(price=Decimal("-1.00")) + invalid_currency = _make_price(currency="RUR1") + invalid_range = _make_price(delivery_days_min=5, delivery_days_max=3) + + result = filter_valid_prices( + [ + valid, + invalid_provider, + invalid_price, + invalid_currency, + invalid_range, + {"not": "a price"}, + ] + ) + + assert len(result) == 1 + assert result[0].provider == "cdek" + assert result[0].service_name == "express" + assert result[0].currency == "RUB" + + +def test_filter_valid_prices_excludes_non_finite_prices() -> None: + valid = _make_price(provider="cdek", price=Decimal("199.00")) + invalid_nan = _make_price(price=Decimal("NaN")) + invalid_inf = _make_price(price=Decimal("Infinity")) + invalid_neg_inf = _make_price(price=Decimal("-Infinity")) + + result = filter_valid_prices([invalid_nan, invalid_inf, invalid_neg_inf, valid]) + + assert len(result) == 1 + assert result[0].provider == "cdek" + + +def test_filter_valid_prices_excludes_none_provider_and_service() -> None: + valid = _make_price(provider="cdek", service_name="express") + invalid_provider_none = _make_price(provider=None) + invalid_service_none = _make_price(service_name=None) + + result = filter_valid_prices([invalid_provider_none, invalid_service_none, valid]) + + assert len(result) == 1 + assert result[0].provider == "cdek" + assert result[0].service_name == "express" + + +def test_sort_prices_by_price_keeps_equal_price_order_stable() -> None: + price_a = _make_price(provider="a", price=Decimal("150.00")) + price_b = _make_price(provider="b", price=Decimal("150.00")) + price_c = _make_price(provider="c", price=Decimal("99.99")) + + sorted_prices = sort_prices_by_price([price_a, price_b, price_c]) + + assert [price.provider for price in sorted_prices] == ["c", "a", "b"] + + +def test_filter_and_sort_prices_combines_domain_steps() -> None: + price_a = _make_price(provider="a", price=Decimal("220.00")) + price_b = _make_price(provider="b", price=Decimal("100.00")) + invalid = _make_price(provider="", price=Decimal("50.00")) + + result = filter_and_sort_prices([price_a, invalid, price_b]) + + assert [price.provider for price in result] == ["b", "a"]