Добавлен провайдер доставки 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
@@ -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")
+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]
+14399 -4706
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -46,6 +46,18 @@ class AdapterConfig(BaseModel):
cdek_retry_backoff_seconds: float = Field(default=0.2, ge=0)
cdek_timeout_seconds: float = Field(default=10.0, gt=0)
cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
cse_base_url: str = "https://web.cse.ru/1c/ws/Web1C.1cws"
cse_login: str = ""
cse_password: str = ""
cse_retry_attempts: int = Field(default=2, ge=0)
cse_retry_backoff_seconds: float = Field(default=0.2, ge=0)
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).
cse_payer: str = ""
cse_payment_method: str = ""
cse_shipping_method: str = ""
cse_urgency: str = ""
class TBankPaymentAuthConfig(BaseModel):
+14 -3
View File
@@ -11,6 +11,7 @@ from app.adapters.address_suggestions.yandex_geosuggest import (
YandexGeosuggestAddressSuggestionProvider,
)
from app.adapters.delivery_providers.cdek import CDEKProvider
from app.adapters.delivery_providers.cse import CSEProvider
from app.adapters.email import SMTPEmailSender
from app.adapters.tbank import TBankAdapter
from app.config import Settings
@@ -53,6 +54,10 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client,
adapter_config=settings.adapter,
)
cse_provider = CSEProvider.from_adapter_config(
http_client=http_client,
adapter_config=settings.adapter,
)
payment_adapter = TBankAdapter.from_config(
http_client=http_client,
config=settings.tbank_payment,
@@ -69,7 +74,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client,
config=settings.address_suggestions.tomtom,
)
providers = (cdek_provider,)
providers = (cdek_provider, cse_provider)
cache = PriceCache.from_repository_config(settings.repository)
postgres_engine = create_postgres_engine(settings.postgres)
postgres_session_factory = create_postgres_session_factory(postgres_engine)
@@ -87,9 +92,15 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
providers=providers,
cache=cache,
payment_adapter=payment_adapter,
payment_price_validation_adapter=cdek_provider,
payment_price_validation_adapters={
cdek_provider.name: cdek_provider,
cse_provider.name: cse_provider,
},
order_repository=order_repository,
order_registration_adapter=cdek_provider,
order_registration_adapters={
cdek_provider.name: cdek_provider,
cse_provider.name: cse_provider,
},
email_sender=email_sender,
address_suggestion_providers=(
dadata_provider,
+9 -2
View File
@@ -47,7 +47,7 @@ class ProviderPrice:
currency: str
delivery_days_min: int
delivery_days_max: int
tariff_code: int | None = None
tariff_code: str | None = None
bypass_parcel_type_filter: bool = False
@@ -225,7 +225,7 @@ def _normalize_price(
currency=currency,
delivery_days_min=min_days,
delivery_days_max=max_days,
tariff_code=_try_to_int(_get_optional_attr(candidate, "tariff_code")),
tariff_code=_try_to_str(_get_optional_attr(candidate, "tariff_code")),
bypass_parcel_type_filter=_extract_bypass_parcel_type_filter(candidate),
)
@@ -271,6 +271,13 @@ def _normalize_text(value: object) -> str:
return str(value).strip()
def _try_to_str(value: object) -> str | None:
if value is _MISSING or value is None:
return None
text = str(value).strip()
return text or None
def _normalize_city_id(value: object) -> int:
city_id = _try_to_int(value)
if city_id is None:
+7 -1
View File
@@ -32,7 +32,12 @@ class Order(Base):
order_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
payment_url: Mapped[str] = mapped_column(String(2048), nullable=False)
price: Mapped[int] = mapped_column(Integer, nullable=False)
tariff_code: Mapped[int] = mapped_column(Integer, nullable=False)
tariff_code: Mapped[str] = mapped_column(String(128), nullable=False)
provider: Mapped[str] = mapped_column(
String(32),
nullable=False,
server_default="cdek",
)
account_email: Mapped[str] = mapped_column(String(320), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(
_json_payload_type(),
@@ -41,6 +46,7 @@ class Order(Base):
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
cdek_order_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
cse_order_number: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
cdek_waybill_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_waybill_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
+17 -1
View File
@@ -18,7 +18,8 @@ class OrderData:
order_uuid: str
payment_url: str
price: int
tariff_code: int
tariff_code: str
provider: str
account_email: str
payload: dict[str, Any]
@@ -36,6 +37,7 @@ class OrderRepository:
payment_url=order_data.payment_url,
price=order_data.price,
tariff_code=order_data.tariff_code,
provider=order_data.provider,
account_email=order_data.account_email,
payload=order_data.payload,
)
@@ -84,6 +86,20 @@ class OrderRepository:
await session.flush()
return order
async def mark_cse_order_registered(
self,
session: AsyncSession,
order_uuid: str,
cse_order_number: str,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
order.cse_order_number = cse_order_number
await session.flush()
return order
async def list_orders_pending_waybill(
self,
session: AsyncSession,
+1 -1
View File
@@ -83,7 +83,7 @@ class SystemDataTariff(_CamelModel):
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
delivery_days_min: int = Field(ge=0, strict=True)
delivery_days_max: int = Field(ge=0, strict=True)
tariff_code: int = Field(gt=0, strict=True)
tariff_code: str = Field(min_length=1)
class Dimensions(_CamelModel):
+1 -1
View File
@@ -12,7 +12,7 @@ class DeliveryPrice(BaseModel):
currency: str = Field(min_length=3, max_length=3)
delivery_days_min: int = Field(ge=0)
delivery_days_max: int = Field(ge=0)
tariff_code: int | None = Field(default=None, ge=0)
tariff_code: str | None = Field(default=None, min_length=1)
bypass_parcel_type_filter: bool = Field(default=False, exclude=True)
+96 -44
View File
@@ -22,9 +22,6 @@ from app.adapters.delivery_providers.base import (
ProviderClientError,
ProviderRequestError,
)
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentNotificationTokenError,
@@ -121,7 +118,7 @@ class PaymentPriceValidationAdapterProtocol(Protocol):
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult: ...
) -> object: ...
class OrderRepositoryProtocol(Protocol):
@@ -150,6 +147,13 @@ class OrderRepositoryProtocol(Protocol):
cdek_order_uuid: str,
) -> object | None: ...
async def mark_cse_order_registered(
self,
session: object,
order_uuid: str,
cse_order_number: str,
) -> object | None: ...
class EmailSenderProtocol(Protocol):
async def send_email(
@@ -177,11 +181,13 @@ class AggregatorService:
providers: Sequence[DeliveryProvider],
cache: PriceCacheProtocol | None = None,
payment_adapter: PaymentAdapterProtocol | None = None,
payment_price_validation_adapter: (
PaymentPriceValidationAdapterProtocol | None
payment_price_validation_adapters: (
Mapping[str, PaymentPriceValidationAdapterProtocol] | None
) = None,
order_repository: OrderRepositoryProtocol | None = None,
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
order_registration_adapters: (
Mapping[str, OrderRegistrationAdapterProtocol] | None
) = None,
email_sender: EmailSenderProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
@@ -194,9 +200,11 @@ class AggregatorService:
self._providers = tuple(providers)
self._cache = cache
self._payment_adapter = payment_adapter
self._payment_price_validation_adapter = payment_price_validation_adapter
self._payment_price_validation_adapters = dict(
payment_price_validation_adapters or {}
)
self._order_repository = order_repository
self._order_registration_adapter = order_registration_adapter
self._order_registration_adapters = dict(order_registration_adapters or {})
self._email_sender = email_sender
self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier
@@ -323,17 +331,22 @@ class AggregatorService:
request: InitPaymentRequest,
order_uuid: str,
) -> None:
if self._payment_price_validation_adapter is None:
raise InitPaymentUnavailableError(
"Payment price validation adapter is not configured."
provider = request.system_data.tariff.provider
validation_adapter = self._payment_price_validation_adapters.get(provider)
if validation_adapter is None:
logger.warning(
"init_payment_price_validation_provider_not_configured",
order_uuid=order_uuid,
provider=provider,
)
raise InvalidInitPaymentRequestError(
"Payment price validation is not configured for the tariff provider."
)
tariff_code = request.system_data.tariff.tariff_code
requested_price = request.system_data.tariff.price
try:
provider_price = await self._payment_price_validation_adapter.get_payment_price(
request
)
provider_price = await validation_adapter.get_payment_price(request)
except ProviderRequestError as exc:
logger.warning(
"init_payment_price_validation_request_rejected",
@@ -430,16 +443,17 @@ class AggregatorService:
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
return "OK"
existing_cdek_order_uuid = getattr(order, "cdek_order_uuid", None)
if existing_cdek_order_uuid:
provider = self._resolve_order_provider(order)
if self._has_existing_registration(order, provider):
return "OK"
registration_result = await self._register_cdek_order(
order, order_uuid=notification.OrderId
registration_result = await self._register_provider_order(
order, order_uuid=notification.OrderId, provider=provider
)
await self._save_cdek_order_uuid(
await self._save_order_registration(
order_uuid=notification.OrderId,
cdek_order_uuid=registration_result.order_uuid,
provider=provider,
result=registration_result,
)
await self._send_payment_confirmation_email(
order, order_uuid=notification.OrderId
@@ -543,65 +557,102 @@ class AggregatorService:
"TBank payment notification order update failed."
) from exc
async def _register_cdek_order(
self, order: object, *, order_uuid: str
) -> CDEKOrderRegistrationResult:
if self._order_registration_adapter is None:
@staticmethod
def _resolve_order_provider(order: object) -> str:
provider = getattr(order, "provider", None)
if isinstance(provider, str) and provider:
return provider
request = AggregatorService._to_init_payment_request_from_order(order)
return request.system_data.tariff.provider
@staticmethod
def _has_existing_registration(order: object, provider: str) -> bool:
if provider == "cdek":
return bool(getattr(order, "cdek_order_uuid", None))
if provider == "cse":
return bool(getattr(order, "cse_order_number", None))
return False
@staticmethod
def _extract_registration_id(result: object) -> str:
for attr in ("order_uuid", "order_number"):
value = getattr(result, attr, None)
if isinstance(value, str) and value:
return value
raise TBankPaymentNotificationProcessingError(
"Order registration result is missing an identifier."
)
async def _register_provider_order(
self, order: object, *, order_uuid: str, provider: str
) -> object:
registration_adapter = self._order_registration_adapters.get(provider)
if registration_adapter is None:
raise TBankPaymentNotificationProcessingError(
"CDEK order registration adapter is not configured."
"Order registration adapter is not configured for the provider."
)
try:
request = self._to_init_payment_request_from_order(order)
return await self._order_registration_adapter.register_order(
request, order_uuid
)
return await registration_adapter.register_order(request, order_uuid)
except Exception as exc:
logger.exception(
"cdek_order_registration_failed",
"provider_order_registration_failed",
provider=provider,
order_uuid=getattr(order, "order_uuid", None),
)
raise TBankPaymentNotificationProcessingError(
"CDEK order registration failed."
"Provider order registration failed."
) from exc
async def _save_cdek_order_uuid(
async def _save_order_registration(
self,
*,
order_uuid: str,
cdek_order_uuid: str,
provider: str,
result: object,
) -> None:
if self._order_repository is None:
raise TBankPaymentNotificationProcessingError(
"Order repository is not configured."
)
registration_id = self._extract_registration_id(result)
try:
async with self._order_repository.session() as session:
order = await self._order_repository.mark_cdek_order_registered(
session,
order_uuid,
cdek_order_uuid,
)
if provider == "cse":
order = await self._order_repository.mark_cse_order_registered(
session,
order_uuid,
registration_id,
)
else:
order = await self._order_repository.mark_cdek_order_registered(
session,
order_uuid,
registration_id,
)
if order is None:
logger.warning(
"cdek_order_uuid_order_not_found",
"order_registration_order_not_found",
provider=provider,
order_uuid=order_uuid,
cdek_order_uuid=cdek_order_uuid,
registration_id=registration_id,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found while saving CDEK order UUID."
"Order was not found while saving registration identifier."
)
except TBankPaymentNotificationProcessingError:
raise
except Exception as exc:
logger.exception(
"cdek_order_uuid_persistence_failed",
"order_registration_persistence_failed",
provider=provider,
order_uuid=order_uuid,
cdek_order_uuid=cdek_order_uuid,
registration_id=registration_id,
)
raise TBankPaymentNotificationProcessingError(
"CDEK order UUID persistence failed."
"Order registration persistence failed."
) from exc
async def _persist_order(
@@ -642,6 +693,7 @@ class AggregatorService:
payment_url=payment_url,
price=request.system_data.tariff.price,
tariff_code=request.system_data.tariff.tariff_code,
provider=request.system_data.tariff.provider,
account_email=request.account_email,
payload=request.model_dump(mode="json", by_alias=True),
)