Добавлен провайдер доставки CSE: SOAP-адаптер (Calc + SaveDocuments), маршрутизация init-payment по провайдеру, обобщение tariff_code до строки, география CSE в cities_map
Deploy / deploy (push) Failing after 52s
Deploy / deploy (push) Failing after 52s
This commit is contained in:
@@ -22,7 +22,7 @@ def map_cdek_response(payload: dict[str, Any]) -> list[DeliveryPrice]:
|
||||
def map_cdek_response_for_tariff_code(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
tariff_code: int,
|
||||
tariff_code: str,
|
||||
) -> DeliveryPrice | None:
|
||||
tariff_codes = payload.get("tariff_codes")
|
||||
if not isinstance(tariff_codes, list):
|
||||
@@ -84,19 +84,17 @@ def _get_tariffs(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return tariffs
|
||||
|
||||
|
||||
def _tariff_code_matches(value: object, expected_tariff_code: int) -> bool:
|
||||
if isinstance(value, bool) or value is None:
|
||||
return False
|
||||
try:
|
||||
return int(value) == expected_tariff_code
|
||||
except (TypeError, ValueError):
|
||||
def _tariff_code_matches(value: object, expected_tariff_code: str) -> bool:
|
||||
extracted = _extract_tariff_code(value)
|
||||
if extracted is None:
|
||||
return False
|
||||
return extracted == expected_tariff_code
|
||||
|
||||
|
||||
def _extract_tariff_code(value: object) -> int | None:
|
||||
def _extract_tariff_code(value: object) -> str | None:
|
||||
if isinstance(value, bool) or value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
return str(int(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -48,7 +48,7 @@ def map_cdek_order_request(
|
||||
payload: dict[str, Any] = {
|
||||
"number": order_uuid,
|
||||
"type": 2,
|
||||
"tariff_code": request.system_data.tariff.tariff_code,
|
||||
"tariff_code": _to_cdek_tariff_code(request.system_data.tariff.tariff_code),
|
||||
"print": _CDEK_WAYBILL_PRINT_TYPE,
|
||||
"sender": _map_party(request.sender_contact),
|
||||
"recipient": _map_party(request.receiver_contact),
|
||||
@@ -61,6 +61,15 @@ def map_cdek_order_request(
|
||||
return payload
|
||||
|
||||
|
||||
def _to_cdek_tariff_code(tariff_code: str) -> int:
|
||||
try:
|
||||
return int(tariff_code)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CDEKOrderMappingError(
|
||||
f"CDEK tariff_code must be numeric: {tariff_code!r}."
|
||||
) from exc
|
||||
|
||||
|
||||
def map_cdek_order_response(payload: dict[str, Any]) -> CDEKOrderRegistrationResult:
|
||||
entity = payload.get("entity")
|
||||
if not isinstance(entity, dict):
|
||||
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user