diff --git a/app/adapters/delivery_providers/cse/client.py b/app/adapters/delivery_providers/cse/client.py index 16fbdea..0274137 100644 --- a/app/adapters/delivery_providers/cse/client.py +++ b/app/adapters/delivery_providers/cse/client.py @@ -7,7 +7,10 @@ import httpx import structlog from app.adapters.delivery_providers.base import DeliveryProvider -from app.adapters.delivery_providers.cse.constants import CSE_PROVIDER_NAME +from app.adapters.delivery_providers.cse.constants import ( + CSE_PROVIDER_NAME, + delivery_type_requires_pvz, +) from app.adapters.delivery_providers.cse.errors import ( CSEClientError, CSEMappingError, @@ -24,10 +27,12 @@ from app.adapters.delivery_providers.cse.order_mapper import ( build_calc_body_for_payment, map_cse_save_order_request, map_cse_save_order_response, + split_tariff_code, ) from app.adapters.delivery_providers.cse.soap import ( Element, build_envelope, + make_field, parse_response, ) from app.config import AdapterConfig @@ -71,6 +76,27 @@ class CSEClient: request_error_message="CSE calc request was rejected with status", ) + async def get_delivery_types(self) -> list[tuple[str, str]]: + """Return available delivery schemes as (name, human label) pairs.""" + + body = { + "parameters": Element( + key="parameters", + items=[make_field("Reference", "DeliveryType")], + ) + } + root = await self._post( + "GetReferenceData", + body, + request_error_message="CSE delivery types request was rejected with status", + ) + result: list[tuple[str, str]] = [] + for entry in root.items: + name = entry.value + if name: + result.append((name, entry.field_value("Information") or name)) + return result + async def save_order( self, request: InitPaymentRequest, order_uuid: str ) -> CSEOrderRegistrationResult: @@ -183,6 +209,7 @@ class CSEProvider(DeliveryProvider): def __init__(self, client: CSEClient, *, cache_ttl_seconds: int = 900) -> None: self._client = client self.cache_ttl_seconds = cache_ttl_seconds + self._delivery_types: list[tuple[str, str]] | None = None @classmethod def from_adapter_config( @@ -200,7 +227,6 @@ class CSEProvider(DeliveryProvider): payer=adapter_config.cse_payer, payment_method=adapter_config.cse_payment_method, shipping_method=adapter_config.cse_shipping_method, - urgency=adapter_config.cse_urgency, ), timeout_seconds=adapter_config.cse_timeout_seconds, retry_attempts=adapter_config.cse_retry_attempts, @@ -211,23 +237,71 @@ class CSEProvider(DeliveryProvider): async def get_prices( self, request: DeliveryCalculationRequest ) -> list[DeliveryPrice]: - body = build_calc_body_for_calculation(request) + delivery_types = await self._resolve_delivery_types() + + results = await asyncio.gather( + *( + self._prices_for_delivery_type(request, name, label) + for name, label in delivery_types + ), + return_exceptions=True, + ) + + prices: list[DeliveryPrice] = [] + errors: list[BaseException] = [] + for result in results: + if isinstance(result, BaseException): + errors.append(result) + else: + prices.extend(result) + + if not prices and errors: + raise errors[0] + return prices + + async def _prices_for_delivery_type( + self, + request: DeliveryCalculationRequest, + delivery_type: str, + delivery_type_label: str, + ) -> list[DeliveryPrice]: + body = build_calc_body_for_calculation(request, delivery_type) root = await self._client.calc(body) try: - return map_cse_calc_response(root) + return map_cse_calc_response( + root, + delivery_type=delivery_type, + delivery_type_label=delivery_type_label, + ) except CSEMappingError as exc: raise CSEClientError("CSE calc response payload is invalid.") from exc + 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() + # Exclude schemes that require a pickup point (PVZ) at registration, + # since PVZ is not yet supported (only door-to-door is fulfillable). + usable = [ + (name, label) + for name, label in self._delivery_types + if not delivery_type_requires_pvz(name, label) + ] + # Fall back to the contract-default scheme (empty delivery_type) so + # calculation still works if no usable schemes are returned. + return usable or [("", "")] + async def get_payment_price( self, request: InitPaymentRequest, ) -> DeliveryPrice | None: - body = build_calc_body_for_payment(request) + 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: return map_cse_calc_response_for_tariff_code( root, - tariff_code=request.system_data.tariff.tariff_code, + tariff_code=tariff_code, ) except CSEMappingError as exc: raise CSEClientError( diff --git a/app/adapters/delivery_providers/cse/constants.py b/app/adapters/delivery_providers/cse/constants.py index b3b39e5..90d0376 100644 --- a/app/adapters/delivery_providers/cse/constants.py +++ b/app/adapters/delivery_providers/cse/constants.py @@ -12,6 +12,16 @@ from app.cities import cities_map # Provider name surfaced in unified DeliveryPrice/SystemDataTariff. CSE_PROVIDER_NAME = "cse" +# Keywords in a DeliveryType name/label that imply a pickup point (PVZ) is +# required at registration (SenderPVZ/RecipientPVZ). PVZ is not yet supported, +# so such schemes are excluded from price calculation. +_PVZ_SCHEME_KEYWORDS = ("склад", "самовывоз", "почтов", "пвз") + + +def delivery_type_requires_pvz(name: str, label: str) -> bool: + haystack = f"{name} {label}".lower() + return any(keyword in haystack for keyword in _PVZ_SCHEME_KEYWORDS) + # CSE returns currency short names (e.g. "RUR"); normalize to ISO-4217. _CURRENCY_NAME_TO_CODE = { "RUR": "RUB", diff --git a/app/adapters/delivery_providers/cse/mapper.py b/app/adapters/delivery_providers/cse/mapper.py index 2fdebce..a20085a 100644 --- a/app/adapters/delivery_providers/cse/mapper.py +++ b/app/adapters/delivery_providers/cse/mapper.py @@ -13,12 +13,17 @@ from app.schemas.response import DeliveryPrice _TARIFF_KEY = "Tariff" -def map_cse_calc_response(root: Element) -> list[DeliveryPrice]: +def map_cse_calc_response( + root: Element, + *, + delivery_type: str = "", + delivery_type_label: str = "", +) -> list[DeliveryPrice]: """Map a parsed ``Calc`` ``return`` Element into unified delivery prices.""" prices: list[DeliveryPrice] = [] for tariff in _iter_tariffs(root): - price = _map_tariff(tariff) + price = _map_tariff(tariff, delivery_type, delivery_type_label) if price is not None: prices.append(price) return prices @@ -28,15 +33,23 @@ def map_cse_calc_response_for_tariff_code( root: Element, *, tariff_code: str, + delivery_type_label: str = "", ) -> DeliveryPrice | None: - """Return the tariff matching the given GUID, or ``None`` when absent.""" + """Return the tariff matching ``tariff_code`` (``"|"``).""" + delivery_type, _, urgency = tariff_code.partition("|") for tariff in _iter_tariffs(root): - if tariff.value == tariff_code: - return _map_tariff(tariff) + if _tariff_urgency(tariff) == urgency: + return _map_tariff(tariff, delivery_type, delivery_type_label) return None +def _tariff_urgency(tariff: Element) -> str | None: + """Urgency GUID of a Calc tariff (fallback: the tariff record GUID).""" + + return tariff.field_value("Urgency") or tariff.value + + def _iter_tariffs(root: Element): for destination in root.items: for tariff in destination.items: @@ -44,10 +57,17 @@ def _iter_tariffs(root: Element): yield tariff -def _map_tariff(tariff: Element) -> DeliveryPrice | None: - tariff_code = tariff.value - if not tariff_code: +def _map_tariff( + tariff: Element, + delivery_type: str, + delivery_type_label: str, +) -> DeliveryPrice | None: + 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}" raw_total = tariff.field_value("Total") if raw_total is None: @@ -60,9 +80,12 @@ def _map_tariff(tariff: Element) -> DeliveryPrice | None: if not price.is_finite() or price <= 0: return None - service_name = tariff.field_value("Service") or tariff.field_value("UrgencyName") - if not service_name: - service_name = tariff_code + base_name = tariff.field_value("Service") or tariff.field_value("UrgencyName") + if not base_name: + base_name = urgency + service_name = ( + f"{base_name} — {delivery_type_label}" if delivery_type_label else base_name + ) currency = normalize_currency(tariff.field_value("CurrencyName")) diff --git a/app/adapters/delivery_providers/cse/order_mapper.py b/app/adapters/delivery_providers/cse/order_mapper.py index 9f79d9a..ffff0c3 100644 --- a/app/adapters/delivery_providers/cse/order_mapper.py +++ b/app/adapters/delivery_providers/cse/order_mapper.py @@ -16,12 +16,15 @@ _DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S" @dataclass(frozen=True) class CSEOrderRegistrationParams: - """Contract-specific required parameters for SaveDocuments.""" + """Contract-specific required parameters for SaveDocuments. + + ``Urgency`` is not here: it comes from the tariff the client selected + (``system_data.tariff.tariff_code`` holds the urgency GUID). + """ payer: str payment_method: str shipping_method: str - urgency: str @dataclass(frozen=True) @@ -31,39 +34,54 @@ class CSEOrderRegistrationResult: def build_calc_body_for_calculation( request: DeliveryCalculationRequest, + delivery_type: str = "", ) -> dict[str, Element]: - destination = Element( - key="Destination", - fields=[ - make_field("SenderGeography", resolve_cse_geography(request.from_city)), - make_field("RecipientGeography", resolve_cse_geography(request.to_city)), - make_field("TypeOfCargo", resolve_type_of_cargo(_parcel_type(request))), - make_field("Weight", _format_decimal(request.weight_kg), "float"), - make_field("Qty", "1", "int"), - ], - ) - return _calc_body(destination) + fields = [ + make_field("SenderGeography", resolve_cse_geography(request.from_city)), + make_field("RecipientGeography", resolve_cse_geography(request.to_city)), + make_field("TypeOfCargo", resolve_type_of_cargo(_parcel_type(request))), + make_field("Weight", _format_decimal(request.weight_kg), "float"), + make_field("Qty", "1", "int"), + ] + _append_delivery_type(fields, delivery_type) + return _calc_body(Element(key="Destination", fields=fields)) -def build_calc_body_for_payment(request: InitPaymentRequest) -> dict[str, Element]: +def build_calc_body_for_payment( + request: InitPaymentRequest, + delivery_type: str = "", +) -> dict[str, Element]: system_data = request.system_data - destination = Element( - key="Destination", - fields=[ - make_field( - "SenderGeography", - resolve_cse_geography(request.sender_address.city_id), - ), - make_field( - "RecipientGeography", - resolve_cse_geography(request.receiver_address.city_id), - ), - make_field("TypeOfCargo", resolve_type_of_cargo(system_data.parcel_type)), - make_field("Weight", system_data.weight, "float"), - make_field("Qty", "1", "int"), - ], - ) - return _calc_body(destination) + fields = [ + make_field( + "SenderGeography", + resolve_cse_geography(request.sender_address.city_id), + ), + make_field( + "RecipientGeography", + resolve_cse_geography(request.receiver_address.city_id), + ), + make_field("TypeOfCargo", resolve_type_of_cargo(system_data.parcel_type)), + make_field("Weight", system_data.weight, "float"), + make_field("Qty", "1", "int"), + ] + _append_delivery_type(fields, delivery_type) + return _calc_body(Element(key="Destination", fields=fields)) + + +def _append_delivery_type(fields: list[Element], delivery_type: str) -> None: + if delivery_type: + 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). + + The unified tariff_code encodes both dimensions as ``"|"``. + """ + + delivery_type, _, urgency = tariff_code.partition("|") + return delivery_type, urgency def _calc_body(destination: Element) -> dict[str, Element]: @@ -83,6 +101,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) fields = [ make_field("TakeDate", take_date, "dateTime"), @@ -101,7 +120,7 @@ def map_cse_save_order_request( ), make_field("RecipientAddress", _compose_address(request.receiver_address)), make_field("RecipientPhone", request.receiver_contact.phone), - make_field("Urgency", params.urgency), + make_field("Urgency", urgency), make_field("Payer", params.payer, "float"), make_field("PaymentMethod", params.payment_method, "float"), make_field("ShippingMethod", params.shipping_method), @@ -109,6 +128,8 @@ def map_cse_save_order_request( make_field("Weight", system_data.weight, "float"), make_field("CargoPackageQty", "1", "float"), ] + if delivery_type: + fields.append(make_field("DeliveryOfCargo", delivery_type)) if request.delivery_date is not None: fields.append( diff --git a/app/config.py b/app/config.py index 467f16e..cfae51e 100644 --- a/app/config.py +++ b/app/config.py @@ -54,10 +54,10 @@ class AdapterConfig(BaseModel): cse_timeout_seconds: float = Field(default=10.0, gt=0) cse_cache_ttl_seconds: int = Field(default=900, gt=0) # Contract-specific required parameters for SaveDocuments (order registration). + # Urgency is not here: it comes from the tariff selected by the client. cse_payer: str = "" cse_payment_method: str = "" cse_shipping_method: str = "" - cse_urgency: str = "" class TBankPaymentAuthConfig(BaseModel): diff --git a/config.template.yaml b/config.template.yaml index 365c4b8..2aeedf5 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -29,10 +29,9 @@ adapter: cse_retry_backoff_seconds: 0.2 cse_timeout_seconds: 10.0 cse_cache_ttl_seconds: 900 - cse_payer: "${CSE_PAYER}" - cse_payment_method: "${CSE_PAYMENT_METHOD}" - cse_shipping_method: "${CSE_SHIPPING_METHOD}" - cse_urgency: "${CSE_URGENCY}" + cse_payer: "0" # Заказчик + cse_payment_method: "1" # Безналичный расчёт + cse_shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd" # авто tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" diff --git a/config.test.yaml b/config.test.yaml index 72e5a7f..fb38b51 100644 --- a/config.test.yaml +++ b/config.test.yaml @@ -29,10 +29,9 @@ adapter: cse_retry_backoff_seconds: 0.2 cse_timeout_seconds: 10.0 cse_cache_ttl_seconds: 900 - cse_payer: "test-payer" - cse_payment_method: "test-payment-method" - cse_shipping_method: "test-shipping-method" - cse_urgency: "test-urgency" + cse_payer: "0" + cse_payment_method: "1" + cse_shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd" tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" diff --git a/tests/adapters/delivery_providers/cse/test_cse_adapter.py b/tests/adapters/delivery_providers/cse/test_cse_adapter.py index 0ec6833..6bfd077 100644 --- a/tests/adapters/delivery_providers/cse/test_cse_adapter.py +++ b/tests/adapters/delivery_providers/cse/test_cse_adapter.py @@ -35,6 +35,7 @@ _CALC_RESPONSE = """ Total1114.92 CurrencyNameRUR ServiceРоссия доставка + Urgencyurg-std MinPeriod3 MaxPeriod5 @@ -44,6 +45,7 @@ _CALC_RESPONSE = """ Total2000.00 CurrencyNameRUR ServiceЭкспресс + Urgencyurg-exp MinPeriod1 MaxPeriod2 @@ -83,6 +85,27 @@ _ERROR_RESPONSE = """ """ +_DELIVERY_TYPES_RESPONSE = """ + + + + + deliverytype + + NameOfdeliverytype + ДоставкаДоДверей + InformationДверь-Дверь + + + NameOfdeliverytype + СкладДверь + InformationСклад-Дверь + + + + +""" + class SequenceHTTPClient: def __init__(self, results: list[Any]) -> None: @@ -109,7 +132,6 @@ def _params() -> CSEOrderRegistrationParams: payer="payer-1", payment_method="pm-1", shipping_method="sm-1", - urgency="urgency-1", ) @@ -166,19 +188,29 @@ def test_parse_calc_response_round_trip() -> None: def test_provider_get_prices_maps_all_tariffs() -> None: - response = httpx.Response(200, text=_CALC_RESPONSE) - client, _ = _build_client([response]) + client, http_client = _build_client( + [ + httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE), + httpx.Response(200, text=_CALC_RESPONSE), + ] + ) provider = CSEProvider(client) 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 assert [price.provider for price in prices] == ["cse", "cse"] - assert [price.tariff_code for price in prices] == ["tariff-guid-1", "tariff-guid-2"] + assert [price.tariff_code for price in prices] == [ + "ДоставкаДоДверей|urg-std", + "ДоставкаДоДверей|urg-exp", + ] assert prices[0].price == Decimal("1114.92") assert prices[0].currency == "RUB" assert prices[0].delivery_days_min == 3 assert prices[0].delivery_days_max == 5 - assert prices[0].service_name == "Россия доставка" + assert prices[0].service_name == "Россия доставка — Дверь-Дверь" def test_provider_get_payment_price_selects_matching_tariff() -> None: @@ -193,7 +225,7 @@ def test_provider_get_payment_price_selects_matching_tariff() -> None: "price": 200000, "deliveryDaysMin": 1, "deliveryDaysMax": 2, - "tariffCode": "tariff-guid-2", + "tariffCode": "ДоставкаДоДверей|urg-exp", }, "parcelType": "parcel", "weight": "1.0", @@ -204,7 +236,7 @@ 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 == "tariff-guid-2" + assert price.tariff_code == "ДоставкаДоДверей|urg-exp" assert price.price == Decimal("2000.00") @@ -221,9 +253,38 @@ def test_provider_register_order_returns_document_number() -> None: assert http_client.calls[0]["url"] == "http://lk-test.cse.ru/1c/ws/web1c.1cws" +def test_save_order_request_uses_selected_tariff_urgency() -> None: + request = make_init_payment_request( + systemData={ + "tariff": { + "provider": "cse", + "serviceName": "Экспресс", + "price": 200000, + "deliveryDaysMin": 1, + "deliveryDaysMax": 2, + "tariffCode": "ДоставкаДоДверей|urg-exp", + }, + "parcelType": "parcel", + "weight": "1.0", + "dimensions": {"length": "10", "width": "10", "height": "10"}, + } + ) + body = order_mapper.map_cse_save_order_request(request, "order-uuid-1", _params()) + order = body["data"].items[0] + + assert order.field_value("Urgency") == "urg-exp" + assert order.field_value("DeliveryOfCargo") == "ДоставкаДоДверей" + assert order.field_value("Payer") == "payer-1" + assert order.field_value("ShippingMethod") == "sm-1" + + def test_application_error_in_response_raises_request_error() -> None: - response = httpx.Response(200, text=_ERROR_RESPONSE) - client, _ = _build_client([response]) + client, _ = _build_client( + [ + httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE), + httpx.Response(200, text=_ERROR_RESPONSE), + ] + ) provider = CSEProvider(client) with pytest.raises(CSERequestError):