Добавлен провайдер доставки CSE: SOAP-адаптер (Calc + SaveDocuments), маршрутизация init-payment по провайдеру, обобщение tariff_code до строки, география CSE в cities_map
Deploy / deploy (push) Failing after 52s

This commit is contained in:
Раис Юсупалиев
2026-05-31 20:13:52 +03:00
parent 6a2bf05ba5
commit 6c0f97adf6
41 changed files with 16113 additions and 4806 deletions
@@ -0,0 +1,15 @@
"""CSE delivery provider adapter."""
from app.adapters.delivery_providers.cse.client import (
CSEClient,
CSEClientError,
CSEProvider,
CSERequestError,
)
__all__ = (
"CSEClient",
"CSEClientError",
"CSEProvider",
"CSERequestError",
)
@@ -0,0 +1,240 @@
"""CSE SOAP client and provider adapter."""
import asyncio
from collections.abc import Awaitable, Callable
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.errors import (
CSEClientError,
CSEMappingError,
CSERequestError,
)
from app.adapters.delivery_providers.cse.mapper import (
map_cse_calc_response,
map_cse_calc_response_for_tariff_code,
)
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderRegistrationParams,
CSEOrderRegistrationResult,
build_calc_body_for_calculation,
build_calc_body_for_payment,
map_cse_save_order_request,
map_cse_save_order_response,
)
from app.adapters.delivery_providers.cse.soap import (
Element,
build_envelope,
parse_response,
)
from app.config import AdapterConfig
from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice
log = structlog.get_logger(__name__)
_SOAP_HEADERS = {"Content-Type": "application/soap+xml; charset=utf-8"}
class CSEClient:
def __init__(
self,
http_client: httpx.AsyncClient,
*,
base_url: str,
login: str,
password: str,
registration_params: CSEOrderRegistrationParams,
timeout_seconds: float = 10.0,
retry_attempts: int = 2,
retry_backoff_seconds: float = 0.2,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> None:
self._http_client = http_client
self._url = base_url
self._login = login
self._password = password
self._registration_params = registration_params
self._timeout_seconds = timeout_seconds
self._retry_attempts = retry_attempts
self._retry_backoff_seconds = retry_backoff_seconds
self._sleep = sleep
async def calc(self, body: dict[str, Element]) -> Element:
return await self._post(
"Calc",
body,
request_error_message="CSE calc request was rejected with status",
)
async def save_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CSEOrderRegistrationResult:
body = map_cse_save_order_request(
request, order_uuid, self._registration_params
)
root = await self._post(
"SaveDocuments",
body,
request_error_message="CSE SaveDocuments request was rejected with status",
)
try:
return map_cse_save_order_response(root)
except CSEMappingError as exc:
raise CSEClientError(str(exc)) from exc
async def _post(
self,
operation: str,
body: dict[str, Element],
*,
request_error_message: str,
) -> Element:
envelope = build_envelope(
operation,
login=self._login,
password=self._password,
body=body,
)
for attempt in range(self._retry_attempts + 1):
try:
response = await self._http_client.post(
self._url,
content=envelope,
headers=_SOAP_HEADERS,
timeout=self._timeout_seconds,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise CSEClientError(
f"CSE {operation} request failed after retry attempts."
) from exc
if self._should_retry(response.status_code):
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
log.warning(
"cse_request_server_error",
operation=operation,
status_code=response.status_code,
)
raise CSEClientError(
f"CSE {operation} request failed with status {response.status_code}."
)
if 400 <= response.status_code < 500:
log.warning(
"cse_request_rejected",
operation=operation,
status_code=response.status_code,
)
raise CSERequestError(
f"{request_error_message} {response.status_code}."
)
try:
root = parse_response(response.text, operation)
except ValueError as exc:
raise CSEClientError(
f"CSE {operation} response payload is invalid."
) from exc
self._raise_for_response_error(root, operation, request_error_message)
return root
raise CSEClientError(f"CSE {operation} request failed unexpectedly.")
@staticmethod
def _raise_for_response_error(
root: Element,
operation: str,
request_error_message: str,
) -> None:
for prop in root.properties:
if prop.key == "Error":
codes = [item.value for item in prop.items if item.value]
log.warning(
"cse_response_error",
operation=operation,
error_codes=codes,
)
raise CSERequestError(
f"{request_error_message} application error {codes}."
)
@staticmethod
def _should_retry(status_code: int) -> bool:
return status_code == 429 or 500 <= status_code < 600
def _retry_delay(self, attempt: int) -> float:
return self._retry_backoff_seconds * (2**attempt)
class CSEProvider(DeliveryProvider):
name = CSE_PROVIDER_NAME
def __init__(self, client: CSEClient, *, cache_ttl_seconds: int = 900) -> None:
self._client = client
self.cache_ttl_seconds = cache_ttl_seconds
@classmethod
def from_adapter_config(
cls,
*,
http_client: httpx.AsyncClient,
adapter_config: AdapterConfig,
) -> "CSEProvider":
client = CSEClient(
http_client=http_client,
base_url=adapter_config.cse_base_url,
login=adapter_config.cse_login,
password=adapter_config.cse_password,
registration_params=CSEOrderRegistrationParams(
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,
retry_backoff_seconds=adapter_config.cse_retry_backoff_seconds,
)
return cls(client=client, cache_ttl_seconds=adapter_config.cse_cache_ttl_seconds)
async def get_prices(
self, request: DeliveryCalculationRequest
) -> list[DeliveryPrice]:
body = build_calc_body_for_calculation(request)
root = await self._client.calc(body)
try:
return map_cse_calc_response(root)
except CSEMappingError as exc:
raise CSEClientError("CSE calc response payload is invalid.") from exc
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None:
body = build_calc_body_for_payment(request)
root = await self._client.calc(body)
try:
return map_cse_calc_response_for_tariff_code(
root,
tariff_code=request.system_data.tariff.tariff_code,
)
except CSEMappingError as exc:
raise CSEClientError(
"CSE payment price validation response payload is invalid."
) from exc
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CSEOrderRegistrationResult:
return await self._client.save_order(request, order_uuid)
@@ -0,0 +1,72 @@
"""CSE reference constants and geography resolution.
CSE identifies geography, cargo type and currency by GUID. Geography GUIDs per
city live in ``cities_map`` under the ``cse`` block (see task 037). Cargo-type
GUIDs come from ``GetReferenceData: TypesOfCargo`` and must be configured with
real values before production use.
"""
from app.adapters.delivery_providers.cse.errors import CSERequestError
from app.cities import cities_map
# Provider name surfaced in unified DeliveryPrice/SystemDataTariff.
CSE_PROVIDER_NAME = "cse"
# CSE returns currency short names (e.g. "RUR"); normalize to ISO-4217.
_CURRENCY_NAME_TO_CODE = {
"RUR": "RUB",
"RUB": "RUB",
"USD": "USD",
"EUR": "EUR",
}
# TypeOfCargo GUIDs by parcel type (GetReferenceData: TypesOfCargo).
TYPE_OF_CARGO_BY_PARCEL_TYPE: dict[str, str] = {
"doc": "81dd8a13-8235-494f-84fd-9c04c51d50ec", # Документы
"parcel": "4aab1fc6-fc2b-473a-8728-58bcd4ff79ba", # Груз
}
# Cargo type used when the parcel type is not known (price calculation flow).
DEFAULT_TYPE_OF_CARGO: str = "4aab1fc6-fc2b-473a-8728-58bcd4ff79ba" # Груз
def normalize_currency(currency_name: str | None) -> str:
if not currency_name:
return "RUB"
return _CURRENCY_NAME_TO_CODE.get(currency_name.strip().upper(), "RUB")
def resolve_type_of_cargo(parcel_type: str | None) -> str:
cargo_guid = (
TYPE_OF_CARGO_BY_PARCEL_TYPE.get(parcel_type)
if parcel_type is not None
else DEFAULT_TYPE_OF_CARGO
)
if not cargo_guid:
raise CSERequestError(
f"CSE cargo type is not configured for parcel type {parcel_type!r}."
)
return cargo_guid
def resolve_cse_geography(city_id: int) -> str:
"""Resolve CSE geography GUID from cities_map by city identifier."""
city_entry = cities_map.get(str(city_id))
if not isinstance(city_entry, dict):
raise CSERequestError(
f"CSE geography is not configured for city id {city_id}."
)
cse_data = city_entry.get("cse")
if not isinstance(cse_data, dict):
raise CSERequestError(
f"CSE geography is not configured for city id {city_id}."
)
geography_uid = cse_data.get("geography_uid")
if not isinstance(geography_uid, str) or not geography_uid:
raise CSERequestError(
f"CSE geography is not configured for city id {city_id}."
)
return geography_uid
@@ -0,0 +1,18 @@
"""CSE adapter error hierarchy."""
from app.adapters.delivery_providers.base import (
ProviderClientError,
ProviderRequestError,
)
class CSEClientError(ProviderClientError):
"""Raised when a CSE request fails for temporary or provider-side reasons."""
class CSERequestError(CSEClientError, ProviderRequestError):
"""Raised when CSE rejects request data as invalid."""
class CSEMappingError(ValueError):
"""Raised when a CSE SOAP response cannot be mapped."""
@@ -0,0 +1,97 @@
"""CSE Calc response mapper to the unified delivery schema."""
from decimal import Decimal, InvalidOperation
from app.adapters.delivery_providers.cse.constants import (
CSE_PROVIDER_NAME,
normalize_currency,
)
from app.adapters.delivery_providers.cse.errors import CSEMappingError
from app.adapters.delivery_providers.cse.soap import Element
from app.schemas.response import DeliveryPrice
_TARIFF_KEY = "Tariff"
def map_cse_calc_response(root: Element) -> 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)
if price is not None:
prices.append(price)
return prices
def map_cse_calc_response_for_tariff_code(
root: Element,
*,
tariff_code: str,
) -> DeliveryPrice | None:
"""Return the tariff matching the given GUID, or ``None`` when absent."""
for tariff in _iter_tariffs(root):
if tariff.value == tariff_code:
return _map_tariff(tariff)
return None
def _iter_tariffs(root: Element):
for destination in root.items:
for tariff in destination.items:
if tariff.key == _TARIFF_KEY:
yield tariff
def _map_tariff(tariff: Element) -> DeliveryPrice | None:
tariff_code = tariff.value
if not tariff_code:
return None
raw_total = tariff.field_value("Total")
if raw_total is None:
raise CSEMappingError("CSE tariff is missing Total.")
try:
price = Decimal(raw_total)
except (InvalidOperation, TypeError, ValueError) as exc:
raise CSEMappingError("CSE tariff Total is not a valid decimal.") from exc
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
currency = normalize_currency(tariff.field_value("CurrencyName"))
min_days = _to_int(tariff.field_value("MinPeriod"))
max_days = _to_int(tariff.field_value("MaxPeriod"))
if min_days is None:
min_days = 0
if max_days is None or max_days < min_days:
max_days = min_days
try:
return DeliveryPrice(
provider=CSE_PROVIDER_NAME,
service_name=str(service_name),
price=price,
currency=currency,
delivery_days_min=min_days,
delivery_days_max=max_days,
tariff_code=tariff_code,
bypass_parcel_type_filter=True,
)
except (ArithmeticError, TypeError, ValueError) as exc:
raise CSEMappingError("CSE tariff fields have invalid values.") from exc
def _to_int(value: str | None) -> int | None:
if value is None or value == "":
return None
try:
return int(Decimal(value))
except (InvalidOperation, TypeError, ValueError):
return None
@@ -0,0 +1,170 @@
"""CSE Calc and SaveDocuments payload mappers."""
from dataclasses import dataclass
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.soap import Element, make_field
from app.schemas.payment import Address, InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest
_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S"
@dataclass(frozen=True)
class CSEOrderRegistrationParams:
"""Contract-specific required parameters for SaveDocuments."""
payer: str
payment_method: str
shipping_method: str
urgency: str
@dataclass(frozen=True)
class CSEOrderRegistrationResult:
order_number: str
def build_calc_body_for_calculation(
request: DeliveryCalculationRequest,
) -> 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)
def build_calc_body_for_payment(request: InitPaymentRequest) -> 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)
def _calc_body(destination: Element) -> dict[str, Element]:
return {
"data": Element(key="Destinations", items=[destination]),
"parameters": Element(
key="parameters",
items=[make_field("ipaddress", "0.0.0.0")],
),
}
def map_cse_save_order_request(
request: InitPaymentRequest,
order_uuid: str,
params: CSEOrderRegistrationParams,
) -> dict[str, Element]:
system_data = request.system_data
take_date = request.pickup_date.strftime(_DATETIME_FORMAT)
fields = [
make_field("TakeDate", take_date, "dateTime"),
make_field("TakeDateOf", take_date, "dateTime"),
make_field("Sender", request.sender_contact.full_name),
make_field(
"SenderGeography",
resolve_cse_geography(request.sender_address.city_id),
),
make_field("SenderAddress", _compose_address(request.sender_address)),
make_field("SenderPhone", request.sender_contact.phone),
make_field("Recipient", request.receiver_contact.full_name),
make_field(
"RecipientGeography",
resolve_cse_geography(request.receiver_address.city_id),
),
make_field("RecipientAddress", _compose_address(request.receiver_address)),
make_field("RecipientPhone", request.receiver_contact.phone),
make_field("Urgency", params.urgency),
make_field("Payer", params.payer, "float"),
make_field("PaymentMethod", params.payment_method, "float"),
make_field("ShippingMethod", params.shipping_method),
make_field("TypeOfCargo", resolve_type_of_cargo(system_data.parcel_type)),
make_field("Weight", system_data.weight, "float"),
make_field("CargoPackageQty", "1", "float"),
]
if request.delivery_date is not None:
fields.append(
make_field(
"DeliveryDate",
request.delivery_date.strftime(_DATETIME_FORMAT),
"dateTime",
)
)
if request.content.description:
fields.append(make_field("CargoDescription", request.content.description))
fields.append(make_field("Comment", request.content.description))
if request.sender_contact.email:
fields.append(make_field("SenderEMail", request.sender_contact.email))
if request.receiver_contact.email:
fields.append(make_field("RecipientEMail", request.receiver_contact.email))
if system_data.parcel_type == "parcel" and system_data.dimensions is not None:
dimensions = system_data.dimensions
fields.append(make_field("Length", dimensions.length, "float"))
fields.append(make_field("Width", dimensions.width, "float"))
fields.append(make_field("Height", dimensions.height, "float"))
order = Element(
key="Order",
fields=fields,
properties=[make_field("ClientNumber", order_uuid)],
)
return {
"data": Element(key="Documents", items=[order]),
"parameters": Element(
key="parameters",
items=[make_field("DocumentType", "Order")],
),
}
def map_cse_save_order_response(root: Element) -> CSEOrderRegistrationResult:
for document in root.items:
number = document.property_value("Number")
if number:
return CSEOrderRegistrationResult(order_number=number)
raise CSEMappingError("CSE SaveDocuments response is missing document Number.")
def _parcel_type(request: DeliveryCalculationRequest) -> str | None:
return request.parcel_type.value if request.parcel_type is not None else None
def _compose_address(address: Address) -> str:
parts = [address.city, address.street, address.house]
line = ", ".join(part for part in parts if part)
if address.apartment:
line = f"{line}, кв. {address.apartment}"
return line
def _format_decimal(value: float) -> str:
return format(value, "g")
+179
View File
@@ -0,0 +1,179 @@
"""SOAP (cargo3) Element serialization and parsing for CSE.
CSE exposes a SOAP/1C web service ("Карго") that transfers data through nested
universal ``Element`` structures (``Key``/``Value``/``ValueType``/``Fields``/
``List``/``Tables``/``Properties``). This module builds request envelopes and
parses responses into a plain Python representation that mappers can navigate.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from xml.etree import ElementTree as ET
CARGO_NS = "http://www.cargo3.ru"
SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"
# Child tags of an Element that hold nested Element lists.
_LIST_TAGS = ("Fields", "List", "Tables", "Properties")
_SCALAR_TAGS = ("Key", "Value", "ValueType")
@dataclass
class Element:
"""In-memory representation of a cargo3 ``Element`` structure."""
key: str | None = None
value: str | None = None
value_type: str | None = None
fields: list["Element"] = field(default_factory=list)
items: list["Element"] = field(default_factory=list) # <List>
tables: list["Element"] = field(default_factory=list)
properties: list["Element"] = field(default_factory=list)
def field_value(self, key: str) -> str | None:
"""Return the ``Value`` of the ``Fields`` entry with the given ``Key``."""
return _find_value(self.fields, key)
def property_value(self, key: str) -> str | None:
"""Return the ``Value`` of the ``Properties`` entry with the given ``Key``."""
return _find_value(self.properties, key)
def _find_value(elements: list["Element"], key: str) -> str | None:
for element in elements:
if element.key == key:
return element.value
return None
def make_field(key: str, value: Any, value_type: str = "string") -> Element:
"""Build a leaf ``Element`` for a ``Fields`` entry."""
return Element(key=key, value=_stringify(value), value_type=value_type)
def _stringify(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def build_envelope(
operation: str,
*,
login: str,
password: str,
body: dict[str, Element],
) -> bytes:
"""Build a SOAP 1.2 request envelope for the given CSE operation.
``body`` maps the operation parameter tag (e.g. ``data``/``parameters``) to
the root ``Element`` placed under that tag.
"""
envelope = ET.Element(f"{{{SOAP_NS}}}Envelope")
soap_body = ET.SubElement(envelope, f"{{{SOAP_NS}}}Body")
op_node = ET.SubElement(soap_body, f"{{{CARGO_NS}}}{operation}")
login_node = ET.SubElement(op_node, f"{{{CARGO_NS}}}login")
login_node.text = login
password_node = ET.SubElement(op_node, f"{{{CARGO_NS}}}password")
password_node.text = password
for tag, element in body.items():
_append_element(op_node, tag, element)
ET.register_namespace("soap", SOAP_NS)
ET.register_namespace("m", CARGO_NS)
return ET.tostring(envelope, encoding="utf-8", xml_declaration=True)
def _append_element(parent: ET.Element, tag: str, element: Element) -> None:
node = ET.SubElement(parent, f"{{{CARGO_NS}}}{tag}")
if element.key is not None:
_scalar(node, "Key", element.key)
if element.value is not None:
_scalar(node, "Value", element.value)
if element.value_type is not None:
_scalar(node, "ValueType", element.value_type)
for child in element.fields:
_append_element(node, "Fields", child)
for child in element.items:
_append_element(node, "List", child)
for child in element.tables:
_append_element(node, "Tables", child)
for child in element.properties:
_append_element(node, "Properties", child)
def _scalar(parent: ET.Element, tag: str, text: str) -> None:
child = ET.SubElement(parent, f"{{{CARGO_NS}}}{tag}")
child.text = text
def parse_response(xml_payload: str | bytes, operation: str) -> Element:
"""Parse a CSE SOAP response and return the ``return`` Element.
Raises ``ValueError`` when the envelope is malformed or the expected
``<operation>Response`` / ``return`` nodes are missing.
"""
try:
root = ET.fromstring(xml_payload)
except ET.ParseError as exc:
raise ValueError("CSE response is not valid XML.") from exc
response_tag = f"{{{CARGO_NS}}}{operation}Response"
response_node = _find_descendant(root, response_tag)
if response_node is None:
raise ValueError(f"CSE response is missing <{operation}Response>.")
return_node = _find_child(response_node, "return")
if return_node is None:
raise ValueError("CSE response is missing <return>.")
return _parse_element(return_node)
def _parse_element(node: ET.Element) -> Element:
element = Element()
for child in node:
local = _localname(child.tag)
if local in _SCALAR_TAGS:
text = (child.text or "").strip()
if local == "Key":
element.key = text
elif local == "Value":
element.value = text
else:
element.value_type = text
elif local == "Fields":
element.fields.append(_parse_element(child))
elif local == "List":
element.items.append(_parse_element(child))
elif local == "Tables":
element.tables.append(_parse_element(child))
elif local == "Properties":
element.properties.append(_parse_element(child))
return element
def _find_descendant(root: ET.Element, tag: str) -> ET.Element | None:
if root.tag == tag:
return root
return root.find(f".//{tag}")
def _find_child(node: ET.Element, local: str) -> ET.Element | None:
for child in node:
if _localname(child.tag) == local:
return child
return None
def _localname(tag: str) -> str:
return tag.rsplit("}", 1)[-1]