From 4262b8a20021bf889c48d97366cd3e2ac29025f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D0=B8=D1=81=20=D0=AE=D1=81=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=B2?= Date: Sat, 27 Jun 2026 17:34:06 +0300 Subject: [PATCH] =?UTF-8?q?fix=20=D0=BA=D1=81=D0=B5=20tariffs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/adapters/delivery_providers/cse/client.py | 35 ++++- app/adapters/delivery_providers/cse/mapper.py | 30 +++- .../delivery_providers/cse/order_mapper.py | 23 ++- app/config.py | 4 + config.template.yaml | 2 + config.test.yaml | 2 + .../cse/test_cse_adapter.py | 144 ++++++++++++++++-- tests/config/test_config_sections.py | 1 + 8 files changed, 207 insertions(+), 34 deletions(-) diff --git a/app/adapters/delivery_providers/cse/client.py b/app/adapters/delivery_providers/cse/client.py index 91e940c..c28fa19 100644 --- a/app/adapters/delivery_providers/cse/client.py +++ b/app/adapters/delivery_providers/cse/client.py @@ -236,9 +236,16 @@ class CSEClient: class CSEProvider(DeliveryProvider): name = CSE_PROVIDER_NAME - def __init__(self, client: CSEClient, *, cache_ttl_seconds: int = 900) -> None: + def __init__( + self, + client: CSEClient, + *, + cache_ttl_seconds: int = 900, + delivery_service_guids: tuple[str, ...] = (), + ) -> None: self._client = client self.cache_ttl_seconds = cache_ttl_seconds + self._delivery_service_guids = delivery_service_guids self._delivery_types: list[tuple[str, str]] | None = None @classmethod @@ -262,7 +269,11 @@ class CSEProvider(DeliveryProvider): retry_attempts=config.retry_attempts, retry_backoff_seconds=config.retry_backoff_seconds, ) - return cls(client=client, cache_ttl_seconds=config.cache_ttl_seconds) + return cls( + client=client, + cache_ttl_seconds=config.cache_ttl_seconds, + delivery_service_guids=tuple(config.delivery_service_guids), + ) async def get_prices( self, request: DeliveryCalculationRequest @@ -271,8 +282,9 @@ class CSEProvider(DeliveryProvider): results = await asyncio.gather( *( - self._prices_for_delivery_type(request, name, label) + self._prices_for_delivery_type(request, name, label, service_guid) for name, label in delivery_types + for service_guid in self._service_guids_for_price_calculation() ), return_exceptions=True, ) @@ -294,18 +306,25 @@ class CSEProvider(DeliveryProvider): request: DeliveryCalculationRequest, delivery_type: str, delivery_type_label: str, + service_guid: str | None, ) -> list[DeliveryPrice]: - body = build_calc_body_for_calculation(request, delivery_type) + body = build_calc_body_for_calculation(request, delivery_type, service_guid) root = await self._client.calc(body) try: return map_cse_calc_response( root, delivery_type=delivery_type, delivery_type_label=delivery_type_label, + service_guid=service_guid, ) except CSEMappingError as exc: raise CSEClientError("CSE calc response payload is invalid.") from exc + def _service_guids_for_price_calculation(self) -> tuple[str, ...]: + if not self._delivery_service_guids: + raise CSERequestError("CSE delivery service GUIDs are not configured.") + return self._delivery_service_guids + async def _resolve_delivery_types(self) -> list[tuple[str, str]]: if self._delivery_types is None: self._delivery_types = await self._client.get_delivery_types() @@ -325,14 +344,16 @@ class CSEProvider(DeliveryProvider): request: InitPaymentRequest, ) -> DeliveryPrice | None: tariff_code = request.system_data.tariff.tariff_code - delivery_type, _ = split_tariff_code(tariff_code) - body = build_calc_body_for_payment(request, delivery_type) - root = await self._client.calc(body) try: + delivery_type, service_guid, _ = split_tariff_code(tariff_code) + body = build_calc_body_for_payment(request, delivery_type, service_guid) + root = await self._client.calc(body) return map_cse_calc_response_for_tariff_code( root, tariff_code=tariff_code, ) + except CSERequestError: + raise except CSEMappingError as exc: raise CSEClientError( "CSE payment price validation response payload is invalid." diff --git a/app/adapters/delivery_providers/cse/mapper.py b/app/adapters/delivery_providers/cse/mapper.py index 7eb5d27..49aa2f8 100644 --- a/app/adapters/delivery_providers/cse/mapper.py +++ b/app/adapters/delivery_providers/cse/mapper.py @@ -18,11 +18,14 @@ def map_cse_calc_response( *, delivery_type: str = "", delivery_type_label: str = "", + service_guid: str | None = None, ) -> list[DeliveryPrice]: """Map a parsed ``Calc`` ``return`` Element into unified delivery prices.""" prices: list[DeliveryPrice] = [] for tariff in _iter_tariffs(root): + if service_guid is not None and tariff.value != service_guid: + continue price = _map_tariff(tariff, delivery_type, delivery_type_label) if price is not None: prices.append(price) @@ -35,11 +38,11 @@ def map_cse_calc_response_for_tariff_code( tariff_code: str, delivery_type_label: str = "", ) -> DeliveryPrice | None: - """Return the tariff matching ``tariff_code`` (``"|"``).""" + """Return tariff matching ``"||"``.""" - delivery_type, _, urgency = tariff_code.partition("|") + delivery_type, tariff_guid, urgency = _split_tariff_code(tariff_code) for tariff in _iter_tariffs(root): - if _tariff_urgency(tariff) == urgency: + if _tariff_matches(tariff, tariff_guid, urgency): price = _map_tariff(tariff, delivery_type, delivery_type_label) if price is not None: return price @@ -52,6 +55,19 @@ def _tariff_urgency(tariff: Element) -> str | None: return tariff.field_value("Urgency") or tariff.value +def _tariff_matches(tariff: Element, tariff_guid: str, urgency: str) -> bool: + if _tariff_urgency(tariff) != urgency: + return False + return tariff.value == tariff_guid + + +def _split_tariff_code(tariff_code: str) -> tuple[str, str, str]: + parts = tariff_code.split("|") + if len(parts) != 3 or not parts[1] or not parts[2]: + raise CSEMappingError("CSE tariff_code has invalid format.") + return parts[0], parts[1], parts[2] + + def _iter_tariffs(root: Element): for destination in root.items: for tariff in destination.items: @@ -70,9 +86,11 @@ def _map_tariff( urgency = _tariff_urgency(tariff) if not urgency: return None - # Unified tariff_code carries both the delivery scheme and the urgency so - # registration (SaveDocuments) can set DeliveryOfCargo and Urgency. - tariff_code = f"{delivery_type}|{urgency}" + if not tariff.value: + return None + # Unified tariff_code carries the delivery scheme, CSE service GUID and + # urgency so payment validation can recalculate the exact selected service. + tariff_code = f"{delivery_type}|{tariff.value}|{urgency}" raw_total = tariff.field_value("Total") if raw_total is None: diff --git a/app/adapters/delivery_providers/cse/order_mapper.py b/app/adapters/delivery_providers/cse/order_mapper.py index bc39c44..7567bdb 100644 --- a/app/adapters/delivery_providers/cse/order_mapper.py +++ b/app/adapters/delivery_providers/cse/order_mapper.py @@ -8,7 +8,7 @@ from app.adapters.delivery_providers.cse.constants import ( resolve_cse_geography, resolve_type_of_cargo, ) -from app.adapters.delivery_providers.cse.errors import CSEMappingError +from app.adapters.delivery_providers.cse.errors import CSEMappingError, CSERequestError from app.adapters.delivery_providers.cse.soap import Element, make_field from app.schemas.payment import Address, InitPaymentRequest from app.schemas.request import DeliveryCalculationRequest @@ -44,6 +44,7 @@ class CSEOrderInfo: def build_calc_body_for_calculation( request: DeliveryCalculationRequest, delivery_type: str = "", + service_guid: str | None = None, ) -> dict[str, Element]: fields = [ make_field("SenderGeography", resolve_cse_geography(request.from_city)), @@ -53,12 +54,15 @@ def build_calc_body_for_calculation( make_field("Qty", "1", "int"), ] _append_delivery_type(fields, delivery_type) + if service_guid: + fields.append(make_field("Service", service_guid)) return _calc_body(Element(key="Destination", fields=fields)) def build_calc_body_for_payment( request: InitPaymentRequest, delivery_type: str = "", + service_guid: str | None = None, ) -> dict[str, Element]: system_data = request.system_data fields = [ @@ -75,6 +79,8 @@ def build_calc_body_for_payment( make_field("Qty", "1", "int"), ] _append_delivery_type(fields, delivery_type) + if service_guid: + fields.append(make_field("Service", service_guid)) return _calc_body(Element(key="Destination", fields=fields)) @@ -83,14 +89,17 @@ def _append_delivery_type(fields: list[Element], delivery_type: str) -> None: fields.append(make_field("DeliveryType", delivery_type)) -def split_tariff_code(tariff_code: str) -> tuple[str, str]: - """Split the CSE ``tariff_code`` into (delivery_type, urgency). +def split_tariff_code(tariff_code: str) -> tuple[str, str, str]: + """Split the CSE ``tariff_code`` into delivery type, service GUID, urgency. - The unified tariff_code encodes both dimensions as ``"|"``. + The expected format is ``"||"``. + ``DeliveryType`` may be empty when CSE contract defaults are used. """ - delivery_type, _, urgency = tariff_code.partition("|") - return delivery_type, urgency + parts = tariff_code.split("|") + if len(parts) != 3 or not parts[1] or not parts[2]: + raise CSERequestError("CSE tariff_code has invalid format.") + return parts[0], parts[1], parts[2] def _calc_body(destination: Element) -> dict[str, Element]: @@ -110,7 +119,7 @@ def map_cse_save_order_request( ) -> dict[str, Element]: system_data = request.system_data take_date = request.pickup_date.strftime(_DATETIME_FORMAT) - delivery_type, urgency = split_tariff_code(system_data.tariff.tariff_code) + delivery_type, _, urgency = split_tariff_code(system_data.tariff.tariff_code) fields = [ make_field("TakeDate", take_date, "dateTime"), diff --git a/app/config.py b/app/config.py index 948175a..9125bf1 100644 --- a/app/config.py +++ b/app/config.py @@ -56,6 +56,10 @@ class CSEDeliveryProviderConfig(DeliveryProviderBaseConfig): base_url: str = "https://web.cse.ru/1c/ws/Web1C.1cws" login: str = "" password: str = "" + # CSE service GUIDs from GetReferenceData: Services that are allowed to be + # shown as delivery tariffs. Additional/non-delivery services must not be + # included here. + delivery_service_guids: list[str] = Field(default_factory=list) # Contract-specific required parameters for SaveDocuments (order registration). # Urgency is not here: it comes from the tariff selected by the client. payer: str = "" diff --git a/config.template.yaml b/config.template.yaml index 65661f8..1621d2a 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -33,6 +33,8 @@ delivery_providers: retry_backoff_seconds: 0.2 timeout_seconds: 10.0 cache_ttl_seconds: 900 + delivery_service_guids: + - "6da21fe8-4f13-11dc-bda1-0015170f8c09" # Россия доставка payer: "0" # Заказчик payment_method: "1" # Безналичный расчёт shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd" # авто diff --git a/config.test.yaml b/config.test.yaml index 50f686b..a1a4b1d 100644 --- a/config.test.yaml +++ b/config.test.yaml @@ -33,6 +33,8 @@ delivery_providers: retry_backoff_seconds: 0.2 timeout_seconds: 10.0 cache_ttl_seconds: 900 + delivery_service_guids: + - "tariff-guid-2" payer: "0" payment_method: "1" shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd" diff --git a/tests/adapters/delivery_providers/cse/test_cse_adapter.py b/tests/adapters/delivery_providers/cse/test_cse_adapter.py index e845813..642fc23 100644 --- a/tests/adapters/delivery_providers/cse/test_cse_adapter.py +++ b/tests/adapters/delivery_providers/cse/test_cse_adapter.py @@ -166,6 +166,43 @@ _CALC_RESPONSE_WITH_ADDITIONAL_SERVICE_FIRST = """ """ +_CALC_RESPONSE_WITH_BUYOUT_SERVICE_FIRST = """ + + + + + Calc + + Destination + + Tariff + buyout-service-guid + Total100.00 + CurrencyNameRUR + + ServiceЧастичный выкуп + + Urgencyurg-exp + + + Tariff + delivery-guid + Total793.00 + CurrencyNameRUR + ServiceЭкспресс + Urgencyurg-exp + + AdditionalServicefalse + + MinPeriod1 + MaxPeriod2 + + + + + +""" + _ERROR_RESPONSE = """ @@ -288,19 +325,23 @@ def test_provider_get_prices_maps_all_tariffs() -> None: [ httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE), httpx.Response(200, text=_CALC_RESPONSE), + httpx.Response(200, text=_CALC_RESPONSE), ] ) - provider = CSEProvider(client) + provider = CSEProvider( + client, + delivery_service_guids=("tariff-guid-1", "tariff-guid-2"), + ) prices = asyncio.run(provider.get_prices(_calc_request())) # PVZ-requiring scheme (Склад-Дверь) is excluded: only delivery types + one - # Calc (for door-to-door) are requested. - assert len(http_client.calls) == 2 + # Calc per configured CSE delivery service are requested. + assert len(http_client.calls) == 3 assert [price.provider for price in prices] == ["cse", "cse"] assert [price.tariff_code for price in prices] == [ - "ДоставкаДоДверей|urg-std", - "ДоставкаДоДверей|urg-exp", + "ДоставкаДоДверей|tariff-guid-1|urg-std", + "ДоставкаДоДверей|tariff-guid-2|urg-exp", ] assert prices[0].price == Decimal("1114.92") assert prices[0].currency == "RUB" @@ -311,7 +352,7 @@ def test_provider_get_prices_maps_all_tariffs() -> None: def test_provider_get_payment_price_selects_matching_tariff() -> None: response = httpx.Response(200, text=_CALC_RESPONSE) - client, _ = _build_client([response]) + client, http_client = _build_client([response]) provider = CSEProvider(client) request = make_init_payment_request( systemData={ @@ -321,7 +362,7 @@ def test_provider_get_payment_price_selects_matching_tariff() -> None: "price": 200000, "deliveryDaysMin": 1, "deliveryDaysMax": 2, - "tariffCode": "ДоставкаДоДверей|urg-exp", + "tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp", }, "parcelType": "parcel", "weight": "1.0", @@ -332,8 +373,11 @@ def test_provider_get_payment_price_selects_matching_tariff() -> None: price = asyncio.run(provider.get_payment_price(request)) assert price is not None - assert price.tariff_code == "ДоставкаДоДверей|urg-exp" + assert price.tariff_code == "ДоставкаДоДверей|tariff-guid-2|urg-exp" assert price.price == Decimal("2000.00") + content = http_client.calls[0]["content"].decode("utf-8") + assert "Service" in content + assert "tariff-guid-2" in content def test_provider_get_payment_price_ignores_matching_additional_service() -> None: @@ -348,7 +392,7 @@ def test_provider_get_payment_price_ignores_matching_additional_service() -> Non "price": 79300, "deliveryDaysMin": 1, "deliveryDaysMax": 2, - "tariffCode": "ДоставкаДоДверей|urg-exp", + "tariffCode": "ДоставкаДоДверей|delivery-guid|urg-exp", }, "parcelType": "parcel", "weight": "1.0", @@ -359,7 +403,34 @@ def test_provider_get_payment_price_ignores_matching_additional_service() -> Non price = asyncio.run(provider.get_payment_price(request)) assert price is not None - assert price.tariff_code == "ДоставкаДоДверей|urg-exp" + assert price.tariff_code == "ДоставкаДоДверей|delivery-guid|urg-exp" + assert price.price == Decimal("793.00") + + +def test_provider_get_payment_price_selects_matching_service_guid() -> None: + response = httpx.Response(200, text=_CALC_RESPONSE_WITH_BUYOUT_SERVICE_FIRST) + client, _ = _build_client([response]) + provider = CSEProvider(client) + request = make_init_payment_request( + systemData={ + "tariff": { + "provider": "cse", + "serviceName": "Экспресс", + "price": 79300, + "deliveryDaysMin": 1, + "deliveryDaysMax": 2, + "tariffCode": "ДоставкаДоДверей|delivery-guid|urg-exp", + }, + "parcelType": "parcel", + "weight": "1.0", + "dimensions": {"length": "10", "width": "10", "height": "10"}, + } + ) + + price = asyncio.run(provider.get_payment_price(request)) + + assert price is not None + assert price.tariff_code == "ДоставкаДоДверей|delivery-guid|urg-exp" assert price.price == Decimal("793.00") @@ -370,7 +441,7 @@ def test_provider_get_prices_excludes_additional_service_tariffs() -> None: httpx.Response(200, text=_CALC_RESPONSE_WITH_ADDITIONAL_SERVICE_FIRST), ] ) - provider = CSEProvider(client) + provider = CSEProvider(client, delivery_service_guids=("delivery-guid",)) prices = asyncio.run(provider.get_prices(_calc_request())) @@ -380,13 +451,58 @@ def test_provider_get_prices_excludes_additional_service_tariffs() -> None: ] +def test_provider_get_prices_uses_configured_service_guid_filter() -> None: + client, http_client = _build_client( + [ + httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE), + httpx.Response(200, text=_CALC_RESPONSE_WITH_BUYOUT_SERVICE_FIRST), + ] + ) + provider = CSEProvider(client, delivery_service_guids=("delivery-guid",)) + + prices = asyncio.run(provider.get_prices(_calc_request())) + + assert [price.price for price in prices] == [Decimal("793.00")] + assert [price.service_name for price in prices] == [ + "Экспресс — Дверь-Дверь" + ] + content = http_client.calls[1]["content"].decode("utf-8") + assert "Service" in content + assert "delivery-guid" in content + + +def test_provider_get_prices_requires_delivery_service_guid_configuration() -> None: + client, _ = _build_client([httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE)]) + provider = CSEProvider(client) + + with pytest.raises(CSERequestError): + asyncio.run(provider.get_prices(_calc_request())) + + def test_provider_register_order_returns_document_number() -> None: response = httpx.Response(200, text=_SAVE_RESPONSE) client, http_client = _build_client([response]) provider = CSEProvider(client) result = asyncio.run( - provider.register_order(make_init_payment_request(), "order-uuid-1") + provider.register_order( + make_init_payment_request( + systemData={ + "tariff": { + "provider": "cse", + "serviceName": "Экспресс", + "price": 200000, + "deliveryDaysMin": 1, + "deliveryDaysMax": 2, + "tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp", + }, + "parcelType": "parcel", + "weight": "1.0", + "dimensions": {"length": "10", "width": "10", "height": "10"}, + } + ), + "order-uuid-1", + ) ) assert result.order_number == "CSE-000123" @@ -436,7 +552,7 @@ def test_save_order_request_uses_selected_tariff_urgency() -> None: "price": 200000, "deliveryDaysMin": 1, "deliveryDaysMax": 2, - "tariffCode": "ДоставкаДоДверей|urg-exp", + "tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp", }, "parcelType": "parcel", "weight": "1.0", @@ -460,7 +576,7 @@ def test_application_error_in_response_raises_request_error() -> None: httpx.Response(200, text=_ERROR_RESPONSE), ] ) - provider = CSEProvider(client) + provider = CSEProvider(client, delivery_service_guids=("delivery-guid",)) with pytest.raises(CSERequestError): asyncio.run(provider.get_prices(_calc_request())) diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index 583fd94..0a29263 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -193,6 +193,7 @@ def test_configuration_sections_are_loaded_from_yaml_file( assert settings.delivery_providers.cse.retry_backoff_seconds == 0.2 assert settings.delivery_providers.cse.timeout_seconds == 10.0 assert settings.delivery_providers.cse.cache_ttl_seconds == 900 + assert settings.delivery_providers.cse.delivery_service_guids == ["tariff-guid-2"] assert settings.delivery_providers.cse.payer == "0" assert settings.delivery_providers.cse.payment_method == "1" assert (