Добавлен провайдер доставки 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
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
docker buildx build \
--platform linux/amd64 \
--push \
-t gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9 \
-t gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0 \
.
- name: Setup SSH
+2
View File
@@ -6,3 +6,5 @@ __pycache__
http-client.private.env.json
scripts
.codex
cse-api.pdf
cse_geography*.tsv
@@ -0,0 +1,34 @@
"""Change orders.tariff_code to string."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260530_037"
down_revision: str | None = "20260527_036"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.alter_column(
"orders",
"tariff_code",
existing_type=sa.Integer(),
type_=sa.String(length=128),
existing_nullable=False,
postgresql_using="tariff_code::varchar",
)
def downgrade() -> None:
op.alter_column(
"orders",
"tariff_code",
existing_type=sa.String(length=128),
type_=sa.Integer(),
existing_nullable=False,
postgresql_using="tariff_code::integer",
)
@@ -0,0 +1,33 @@
"""Add provider and CSE order number columns to orders table."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260530_038"
down_revision: str | None = "20260530_037"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column(
"provider",
sa.String(length=32),
nullable=False,
server_default="cdek",
),
)
op.add_column(
"orders",
sa.Column("cse_order_number", sa.String(length=128), nullable=True),
)
def downgrade() -> None:
op.drop_column("orders", "cse_order_number")
op.drop_column("orders", "provider")
@@ -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)
+92 -40
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(
"CDEK order registration adapter is not configured."
"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(
"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:
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,
cdek_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),
)
+11
View File
@@ -22,6 +22,17 @@ adapter:
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
cse_base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
cse_login: "${CSE_LOGIN}"
cse_password: "${CSE_PASSWORD}"
cse_retry_attempts: 2
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}"
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
+11
View File
@@ -22,6 +22,17 @@ adapter:
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
cse_base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
cse_login: "test"
cse_password: "2016"
cse_retry_attempts: 2
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"
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
+4 -4
View File
@@ -1,6 +1,6 @@
services:
app:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0
container_name: g2s-aggregator
ports:
- "8003:8000"
@@ -42,7 +42,7 @@ services:
timeout: 3s
retries: 10
migrations:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0
container_name: g2s-aggregator-migrations
depends_on:
postgres:
@@ -56,7 +56,7 @@ services:
options:
tag: "g2s-aggregator-migrations"
waybill-poller:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0
container_name: g2s-aggregator-waybill-poller
depends_on:
postgres:
@@ -72,7 +72,7 @@ services:
options:
tag: "g2s-aggregator-waybill-poller"
waybill-email-sender:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0
container_name: g2s-aggregator-waybill-email-sender
depends_on:
postgres:
+4
View File
@@ -120,3 +120,7 @@ Authorization: Bearer {{auth_token}}
Accept: application/pdf
>>! waybill.pdf
### 10. Список городов
GET {{base_url}}/v2/location/cities?code=2662
Authorization: Bearer {{auth_token}}
+6 -2
View File
@@ -41,9 +41,13 @@
| 032 | TODO | 2026-05-13 | Rework init-payment contract to camelCase and structured address/contact | `spec/tasks/032_rework_init_payment_contract_camelcase.md` |
| 033 | DONE | 2026-05-23 | Add CDEK waybill polling worker | `spec/tasks/033_add_waybill_polling_worker.md` |
| 034 | DONE | 2026-05-23 | Add CDEK waybill e-mail sender worker | `spec/tasks/034_add_waybill_email_sender_worker.md` |
| 035 | TODO | 2026-05-30 | Generalize tariff_code to string identifier | `spec/tasks/035_generalize_tariff_code_to_string.md` |
| 036 | TODO | 2026-05-30 | Add CSE SOAP delivery provider adapter | `spec/tasks/036_add_cse_delivery_provider_adapter.md` |
| 037 | TODO | 2026-05-30 | Add CSE geography to cities map | `spec/tasks/037_add_cse_geography_to_cities_map.md` |
| 038 | TODO | 2026-05-30 | Route init-payment validation and registration by provider | `spec/tasks/038_route_init_payment_by_provider.md` |
## Summary
- Total: **35**
- TODO: **2**
- Total: **39**
- TODO: **6**
- DONE: **33**
@@ -0,0 +1,44 @@
---
id: 035
title: Generalize tariff_code to string identifier
status: TODO
created: 2026-05-30
---
## Context
Добавляется второй провайдер доставки CSE («Карго»), который идентифицирует тариф
GUID-строкой. Текущие схемы и persistence используют числовой `tariff_code`
(`int`), несовместимый с CSE. Требуется обобщить идентификатор тарифа до строки для
обоих провайдеров.
## Goal
Перевести `tariff_code` на строковый тип в API-схемах и в persistence, сохранив
обратное поведение CDEK (числовой код передаётся как строка).
## Constraints
- Не изменять бизнес-правила в `app/domain/`.
- CDEK adapter должен формировать `tariff_code` как строку из своего числового кода.
- Сохранить совместимость существующего init-payment контракта, кроме типа
`tariff_code`.
- Изменение типа колонки выполнить миграцией Alembic.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `DeliveryPrice.tariff_code: str | None`.
- `SystemDataTariff.tariff_code: str` с непустым значением.
- CDEK `mapper`/`order_mapper` корректно работают со строковым `tariff_code`.
- Колонка `orders.tariff_code` имеет строковый тип; добавлена миграция.
- Существующие тесты обновлены под строковый `tariff_code`.
## Definition of Done
- [ ] Обновлены схемы `DeliveryPrice` и `SystemDataTariff`.
- [ ] Обновлены CDEK мапперы.
- [ ] Обновлены модель `Order` и миграция Alembic.
- [ ] Затронутые тесты проходят.
## Tests
- Обновить unit-тесты CDEK мапперов и схем.
- Проверить миграцию на тестовой БД.
## Commands
- `poetry run pytest -q`
@@ -0,0 +1,58 @@
---
id: 036
title: Add CSE SOAP delivery provider adapter
status: TODO
created: 2026-05-30
---
## Context
Нужен второй провайдер доставки CSE (система «Карго»). CSE предоставляет SOAP/1C
web-сервис (namespace `http://www.cargo3.ru`, endpoint `Web1C.1cws`), авторизация —
`login`/`password` строковыми параметрами в теле запроса. Данные передаются
универсальными структурами `Element`/`Row`; справочные значения — GUID. Спецификация
методов: `Calc` (расчёт стоимости) и `SaveDocuments` (регистрация заказа). Поллинг
статуса и получение PDF в этой задаче не реализуются.
## Goal
Реализовать adapter-пакет CSE: SOAP-транспорт и сериализация `Element`,
`CSEClient` с методами `calc()`/`save_order()`, мапперы расчёта и регистрации, и
`CSEProvider`, реализующий контракт `DeliveryProvider` плюс расчёт цены для
валидации платежа и регистрацию заказа.
## Constraints
- Только Adapter layer; без business decisions, фильтрации и сортировки.
- Весь XML/HTTP IO инкапсулирован в пакете `cse/`.
- Параметры CSE (`base_url`, `login`, `password`, retry policy, timeout, cache TTL)
приходят из секции `adapter` YAML-конфига; запрещён хардкод credentials.
- Ошибки CSE (`Properties.Error``List[Description]`) маппятся в иерархию
`ProviderClientError`/`ProviderRequestError`.
- Валюта `RUR` нормализуется в `RUB`; цена — `Decimal` в рублях.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует пакет `app/adapters/delivery_providers/cse/` с SOAP-сериализацией,
клиентом, мапперами и `CSEProvider` (`name = "cse"`).
- `CSEProvider.get_prices` возвращает унифицированные `DeliveryPrice` из ответа
`Calc` с `provider="cse"` и строковым `tariff_code` (GUID).
- `CSEProvider.get_payment_price` возвращает выбранный тариф для валидации платежа.
- `CSEProvider.register_order` выполняет `SaveDocuments` (`DocumentType=Order`) и
возвращает результат регистрации с номером документа.
- Адаптер использует значения из секции `adapter` YAML-конфига, включая
`cse_timeout_seconds` и `cse_cache_ttl_seconds`.
- Добавлены поля `cse_*` в `AdapterConfig` и шаблоны конфигов.
## Definition of Done
- [ ] Реализованы `soap`, `client`, `errors`, `mapper`, `order_mapper`, `CSEProvider`.
- [ ] Добавлены `cse_*` поля в `AdapterConfig`, `config.template.yaml`,
`config.test.yaml`.
- [ ] Adapter unit-тесты покрывают сериализацию/парсинг, mapping `Calc`/`SaveDocuments`
и обработку ошибок.
## Tests
- Unit-тесты SOAP-сериализации и парсинга `Element`.
- Unit-тесты мапперов на XML-примерах из спецификации.
- Тесты применения параметров CSE из секции `adapter`.
- Для внешних HTTP взаимодействий использовать stubs/mocks.
## Commands
- `poetry run pytest tests/adapters/delivery_providers/cse -q`
@@ -0,0 +1,39 @@
---
id: 037
title: Add CSE geography to cities map
status: TODO
created: 2026-05-30
---
## Context
Методы CSE `Calc` и `SaveDocuments` требуют географию отправления/доставки в виде
GUID (либо ФИАС/postcode). В проекте сопоставление город → коды провайдера хранится
в `app/cities.py` (блок `cdek` на каждый город). Для CSE требуется аналогичный блок
`cse` с GUID географии.
## Goal
Расширить `cities_map` данными географии CSE и зафиксировать справочные константы
CSE (вид груза `TypeOfCargo`, валюта `RUR`), необходимые адаптеру для запросов.
## Constraints
- Не изменять структуру существующего блока `cdek`.
- Города без поддержки CSE имеют `cse: None`.
- Константы вида груза/валюты держать в пакете адаптера `cse/` или в конфиге, без
бизнес-логики.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Каждый город в `cities_map` содержит блок `cse` (GUID географии) или `None`.
- Маппинг `parcel_type` (`doc`/`parcel`) → `TypeOfCargo` GUID и GUID валюты `RUR`
определены и используются адаптером CSE.
## Definition of Done
- [ ] `cities_map` дополнен блоком `cse`.
- [ ] Определены константы вида груза и валюты CSE.
- [ ] Тесты подтверждают чтение географии CSE для поддерживаемого города.
## Tests
- Unit-тесты сопоставления город → география CSE.
## Commands
- `poetry run pytest -q -k cities`
@@ -0,0 +1,48 @@
---
id: 038
title: Route init-payment validation and registration by provider
status: TODO
created: 2026-05-30
---
## Context
С появлением второго провайдера CSE `AggregatorService` должен выбирать адаптер
валидации цены и регистрации заказа по провайдеру выбранного тарифа. Сейчас сервис
жёстко использует единственный CDEK-адаптер. Поле `provider` уже присутствует в
`DeliveryPrice`/`SystemDataTariff`. Регистрация заказа выполняется при уведомлении
об оплате TBank. Поллинг статуса и email в этой задаче не затрагиваются.
## Goal
Маршрутизировать валидацию цены и регистрацию заказа по
`request.system_data.tariff.provider`, подключить `CSEProvider` в DI рядом с CDEK,
сохранять выбранного провайдера и номер заказа CSE в persistence.
## Constraints
- Controller вызывает Service без бизнес-ветвления.
- Service не содержит чистых бизнес-правил; маршрутизация — оркестрация.
- Не изменять контракт API, кроме уже обобщённого `tariff_code`.
- Колонки `cdek_*` не изменять; добавить `provider` и `cse_order_number` миграцией.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `GET /api/v1/delivery/calculate` возвращает агрегированные тарифы CDEK и CSE.
- `AggregatorService` выбирает адаптер валидации/регистрации по имени провайдера
тарифа; общий тип результата регистрации не привязан к CDEK.
- При оплате заказа с тарифом CSE регистрация выполняется через `CSEProvider`;
в `orders` сохранены `provider` и `cse_order_number`.
- DI в контроллере доставки инстанцирует и подключает `CSEProvider`.
## Definition of Done
- [ ] `AggregatorService` маршрутизирует по провайдеру (mapping адаптеров).
- [ ] Обобщён тип результата регистрации заказа.
- [ ] Модель `Order` и репозиторий дополнены (`provider`, `cse_order_number`,
`mark_cse_order_registered`); добавлена миграция.
- [ ] DI контроллера подключает CDEK и CSE.
- [ ] Тесты маршрутизации валидации/регистрации по провайдеру проходят.
## Tests
- Service unit-тесты маршрутизации (моки CDEK/CSE адаптеров).
- Тесты persistence для `provider`/`cse_order_number`.
## Commands
- `poetry run pytest tests/services -q`
@@ -34,7 +34,7 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
assert [price.currency for price in result] == ["RUB", "USD"]
assert [price.delivery_days_min for price in result] == [2, 5]
assert [price.delivery_days_max for price in result] == [4, 7]
assert [price.tariff_code for price in result] == [7, 136]
assert [price.tariff_code for price in result] == ["7", "136"]
def test_map_cdek_response_returns_none_tariff_code_when_missing() -> None:
@@ -129,7 +129,7 @@ def test_order_payload_for_doc_omits_dimensions() -> None:
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
"tariffCode": "535",
},
"parcelType": "doc",
"docPackaging": "envelope",
@@ -132,7 +132,7 @@ def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
"tariffCode": "535",
},
"parcelType": "doc",
"docPackaging": "envelope",
@@ -0,0 +1,230 @@
import asyncio
from decimal import Decimal
from typing import Any
import httpx
import pytest
from app.adapters.delivery_providers.cse import (
CSEClient,
CSEProvider,
CSERequestError,
)
from app.adapters.delivery_providers.cse import order_mapper
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderRegistrationParams,
)
from app.adapters.delivery_providers.cse.soap import (
build_envelope,
parse_response,
)
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
from tests.payment_fixtures import make_init_payment_request
_CALC_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:CalcResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>Calc</m:Key>
<m:List>
<m:Key>Destination</m:Key>
<m:List>
<m:Key>Tariff</m:Key>
<m:Value>tariff-guid-1</m:Value>
<m:Fields><m:Key>Total</m:Key><m:Value>1114.92</m:Value></m:Fields>
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
<m:Fields><m:Key>Service</m:Key><m:Value>Россия доставка</m:Value></m:Fields>
<m:Fields><m:Key>MinPeriod</m:Key><m:Value>3</m:Value></m:Fields>
<m:Fields><m:Key>MaxPeriod</m:Key><m:Value>5</m:Value></m:Fields>
</m:List>
<m:List>
<m:Key>Tariff</m:Key>
<m:Value>tariff-guid-2</m:Value>
<m:Fields><m:Key>Total</m:Key><m:Value>2000.00</m:Value></m:Fields>
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
<m:Fields><m:Key>Service</m:Key><m:Value>Экспресс</m:Value></m:Fields>
<m:Fields><m:Key>MinPeriod</m:Key><m:Value>1</m:Value></m:Fields>
<m:Fields><m:Key>MaxPeriod</m:Key><m:Value>2</m:Value></m:Fields>
</m:List>
</m:List>
</m:return>
</m:CalcResponse>
</soap:Body>
</soap:Envelope>"""
_SAVE_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:SaveDocumentsResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>SaveDocuments</m:Key>
<m:List>
<m:Key>Order</m:Key>
<m:Properties><m:Key>Number</m:Key><m:Value>CSE-000123</m:Value></m:Properties>
</m:List>
</m:return>
</m:SaveDocumentsResponse>
</soap:Body>
</soap:Envelope>"""
_ERROR_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:CalcResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>Calc</m:Key>
<m:Properties>
<m:Key>Error</m:Key>
<m:List><m:Key>Description</m:Key><m:Value>1001</m:Value></m:List>
</m:Properties>
</m:return>
</m:CalcResponse>
</soap:Body>
</soap:Envelope>"""
class SequenceHTTPClient:
def __init__(self, results: list[Any]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
async def post(
self,
url: str,
*,
content: bytes | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
self.calls.append({"url": url, "content": content, "headers": headers})
result = self._results[len(self.calls) - 1]
if isinstance(result, Exception):
raise result
return result
def _params() -> CSEOrderRegistrationParams:
return CSEOrderRegistrationParams(
payer="payer-1",
payment_method="pm-1",
shipping_method="sm-1",
urgency="urgency-1",
)
def _build_client(results: list[Any]) -> tuple[CSEClient, SequenceHTTPClient]:
http_client = SequenceHTTPClient(results)
client = CSEClient(
http_client=http_client, # type: ignore[arg-type]
base_url="http://lk-test.cse.ru/1c/ws/web1c.1cws",
login="test",
password="2016",
registration_params=_params(),
retry_attempts=0,
)
return client, http_client
@pytest.fixture(autouse=True)
def _patch_references(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
order_mapper, "resolve_cse_geography", lambda city_id: f"geo-{city_id}"
)
monkeypatch.setattr(
order_mapper, "resolve_type_of_cargo", lambda parcel_type: "cargo-guid"
)
def _calc_request() -> DeliveryCalculationRequest:
return DeliveryCalculationRequest(
entity=DeliveryEntity.INDIVIDUAL,
from_city=1,
to_city=2,
weight_kg=1.0,
length_cm=10.0,
width_cm=10.0,
height_cm=10.0,
)
def test_build_envelope_contains_credentials_and_payload() -> None:
body = order_mapper.build_calc_body_for_calculation(_calc_request())
envelope = build_envelope("Calc", login="test", password="2016", body=body)
text = envelope.decode("utf-8")
assert "Calc" in text
assert "<m:login>test</m:login>" in text or ">test<" in text
assert "geo-1" in text
assert "geo-2" in text
def test_parse_calc_response_round_trip() -> None:
root = parse_response(_CALC_RESPONSE, "Calc")
assert root.key == "Calc"
assert root.items[0].items[0].field_value("Total") == "1114.92"
def test_provider_get_prices_maps_all_tariffs() -> None:
response = httpx.Response(200, text=_CALC_RESPONSE)
client, _ = _build_client([response])
provider = CSEProvider(client)
prices = asyncio.run(provider.get_prices(_calc_request()))
assert [price.provider for price in prices] == ["cse", "cse"]
assert [price.tariff_code for price in prices] == ["tariff-guid-1", "tariff-guid-2"]
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 == "Россия доставка"
def test_provider_get_payment_price_selects_matching_tariff() -> None:
response = httpx.Response(200, text=_CALC_RESPONSE)
client, _ = _build_client([response])
provider = CSEProvider(client)
request = make_init_payment_request(
systemData={
"tariff": {
"provider": "cse",
"serviceName": "Экспресс",
"price": 200000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "tariff-guid-2",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {"length": "10", "width": "10", "height": "10"},
}
)
price = asyncio.run(provider.get_payment_price(request))
assert price is not None
assert price.tariff_code == "tariff-guid-2"
assert price.price == Decimal("2000.00")
def test_provider_register_order_returns_document_number() -> None:
response = httpx.Response(200, text=_SAVE_RESPONSE)
client, http_client = _build_client([response])
provider = CSEProvider(client)
result = asyncio.run(
provider.register_order(make_init_payment_request(), "order-uuid-1")
)
assert result.order_number == "CSE-000123"
assert http_client.calls[0]["url"] == "http://lk-test.cse.ru/1c/ws/web1c.1cws"
def test_application_error_in_response_raises_request_error() -> None:
response = httpx.Response(200, text=_ERROR_RESPONSE)
client, _ = _build_client([response])
provider = CSEProvider(client)
with pytest.raises(CSERequestError):
asyncio.run(provider.get_prices(_calc_request()))
+1 -1
View File
@@ -63,7 +63,7 @@ def test_post_init_payment_rejects_invalid_tariff_code() -> None:
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["tariff"]["tariffCode"] = "not-a-number"
invalid_payload["systemData"]["tariff"]["tariffCode"] = ""
response = _post(app, invalid_payload)
+2 -2
View File
@@ -57,12 +57,12 @@ def make_init_payment_payload(**overrides: Any) -> dict[str, Any]:
"agreeTerms": True,
"systemData": {
"tariff": {
"provider": "СДЭК",
"provider": "cdek",
"serviceName": "Экспресс лайт",
"price": 125000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
"tariffCode": "535",
},
"parcelType": "parcel",
"docPackaging": None,
+3 -2
View File
@@ -19,7 +19,8 @@ def _make_order_data(**overrides: object) -> OrderData:
"order_uuid": "order-uuid-1",
"payment_url": "https://pay.test/payment/1",
"price": 125000,
"tariff_code": 535,
"tariff_code": "535",
"provider": "cdek",
"account_email": "client@example.com",
"payload": make_init_payment_payload(),
}
@@ -63,7 +64,7 @@ def test_create_order_persists_all_required_fields() -> None:
assert persisted_order.order_uuid == "order-uuid-1"
assert persisted_order.payment_url == "https://pay.test/payment/1"
assert persisted_order.price == 125000
assert persisted_order.tariff_code == 535
assert persisted_order.tariff_code == "535"
assert persisted_order.account_email == "client@example.com"
assert persisted_order.payload == order_data.payload
assert persisted_order.payment_status is None
+10 -10
View File
@@ -119,7 +119,7 @@ def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url(
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
payment_price_validation_adapters={"cdek": validation_adapter},
order_uuid_factory=lambda: "order-uuid-1",
)
@@ -140,7 +140,7 @@ def test_init_payment_persists_order_payload_after_successful_payment_link() ->
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
payment_price_validation_adapters={"cdek": validation_adapter},
order_repository=order_repository,
order_uuid_factory=lambda: "order-uuid-1",
)
@@ -153,7 +153,7 @@ def test_init_payment_persists_order_payload_after_successful_payment_link() ->
assert order_data.order_uuid == "order-uuid-1"
assert order_data.payment_url == "https://pay.test/payment/1"
assert order_data.price == 125000
assert order_data.tariff_code == 535
assert order_data.tariff_code == "535"
assert order_data.account_email == "client@example.com"
assert order_data.payload == request.model_dump(mode="json", by_alias=True)
@@ -166,7 +166,7 @@ def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
payment_price_validation_adapters={"cdek": validation_adapter},
order_repository=order_repository,
order_uuid_factory=lambda: "order-uuid-1",
)
@@ -185,7 +185,7 @@ def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() ->
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
payment_price_validation_adapters={"cdek": validation_adapter},
)
with pytest.raises(InvalidInitPaymentRequestError):
@@ -199,7 +199,7 @@ def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
payment_price_validation_adapters={"cdek": validation_adapter},
)
with pytest.raises(InitPaymentUnavailableError):
@@ -223,7 +223,7 @@ def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_ca
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
payment_price_validation_adapters={"cdek": validation_adapter},
order_repository=order_repository,
)
@@ -241,7 +241,7 @@ def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> No
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
payment_price_validation_adapters={"cdek": validation_adapter},
)
with pytest.raises(InvalidInitPaymentRequestError):
@@ -259,7 +259,7 @@ def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
payment_price_validation_adapters={"cdek": validation_adapter},
)
with pytest.raises(InvalidInitPaymentRequestError):
@@ -275,7 +275,7 @@ def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
payment_price_validation_adapters={"cdek": validation_adapter},
)
with pytest.raises(InitPaymentUnavailableError):
+191
View File
@@ -0,0 +1,191 @@
import asyncio
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
from app.schemas.response import DeliveryPrice
from app.services.aggregator import AggregatorService
from tests.payment_fixtures import make_init_payment_payload, make_init_payment_request
class StubPaymentAdapter:
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
return "https://pay.test/payment/1"
def verify_payment_notification(self, notification: TBankPaymentNotification) -> None:
return None
class StubValidationAdapter:
def __init__(self, response: DeliveryPrice | None) -> None:
self._response = response
self.calls: list[InitPaymentRequest] = []
async def get_payment_price(
self, request: InitPaymentRequest
) -> DeliveryPrice | None:
self.calls.append(request)
return self._response
@dataclass
class CSEResult:
order_number: str = "CSE-000123"
class StubCSERegistrationAdapter:
def __init__(self) -> None:
self.calls: list[tuple[InitPaymentRequest, str]] = []
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CSEResult:
self.calls.append((request, order_uuid))
return CSEResult()
class StubCDEKRegistrationAdapter:
def __init__(self) -> None:
self.calls: list[tuple[InitPaymentRequest, str]] = []
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> object:
self.calls.append((request, order_uuid))
raise AssertionError("CDEK adapter must not be called for a CSE order.")
def _cse_price() -> DeliveryPrice:
return DeliveryPrice.model_construct(
provider="cse",
service_name="CSE tariff",
price=Decimal("1250.00"),
currency="RUB",
delivery_days_min=1,
delivery_days_max=2,
tariff_code="cse-guid",
)
def _cse_request() -> InitPaymentRequest:
return make_init_payment_request(
systemData={
"tariff": {
"provider": "cse",
"serviceName": "CSE tariff",
"price": 125000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "cse-guid",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {"length": "10", "width": "10", "height": "10"},
}
)
def test_init_payment_routes_validation_to_tariff_provider() -> None:
cse_validation = StubValidationAdapter(_cse_price())
cdek_validation = StubValidationAdapter(None)
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
payment_price_validation_adapters={
"cdek": cdek_validation,
"cse": cse_validation,
},
provider_price_multiplier=Decimal("1"),
order_uuid_factory=lambda: "order-uuid-1",
)
asyncio.run(service.init_payment(_cse_request()))
assert len(cse_validation.calls) == 1
assert cdek_validation.calls == []
@dataclass
class StoredOrder:
order_uuid: str = "order-uuid-1"
provider: str = "cse"
payload: dict[str, Any] = field(
default_factory=lambda: _cse_request().model_dump(mode="json", by_alias=True)
)
payment_status: str | None = None
tbank_payment_id: int | None = None
cdek_order_uuid: str | None = None
cse_order_number: str | None = None
payment_email_sent_at: object | None = None
account_email: str | None = None
class StubSession:
async def __aenter__(self) -> object:
return object()
async def __aexit__(self, *args: object) -> None:
return None
class StubOrderRepository:
def __init__(self, order: StoredOrder) -> None:
self._order = order
def session(self) -> StubSession:
return StubSession()
async def get_order_by_order_uuid(self, session: object, order_uuid: str) -> StoredOrder:
return self._order
async def mark_payment_status(
self, session: object, order_uuid: str, status: str, payment_id: int
) -> StoredOrder:
self._order.payment_status = status
self._order.tbank_payment_id = payment_id
return self._order
async def mark_cse_order_registered(
self, session: object, order_uuid: str, cse_order_number: str
) -> StoredOrder:
self._order.cse_order_number = cse_order_number
return self._order
async def mark_cdek_order_registered(
self, session: object, order_uuid: str, cdek_order_uuid: str
) -> StoredOrder:
raise AssertionError("CDEK persistence must not be used for a CSE order.")
def _notification() -> TBankPaymentNotification:
return TBankPaymentNotification(
TerminalKey="TestTerminal",
OrderId="order-uuid-1",
Success=True,
Status="CONFIRMED",
PaymentId=8347568144,
ErrorCode="0",
Amount=125000,
Token="signed-token",
)
def test_notification_routes_registration_to_order_provider() -> None:
order = StoredOrder()
cse_registration = StubCSERegistrationAdapter()
service = AggregatorService(
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(order),
order_registration_adapters={
"cdek": StubCDEKRegistrationAdapter(),
"cse": cse_registration,
},
)
result = asyncio.run(service.handle_tbank_payment_notification(_notification()))
assert result == "OK"
assert len(cse_registration.calls) == 1
assert order.cse_order_number == "CSE-000123"
+14 -12
View File
@@ -26,12 +26,14 @@ class StoredOrder:
order_uuid: str = "order-uuid-1"
payment_url: str = "https://pay.test/payment/1"
price: int = 125000
tariff_code: int = 535
tariff_code: str = "535"
provider: str = "cdek"
account_email: str = "client@example.com"
payload: dict[str, Any] = field(default_factory=_default_payload)
payment_status: str | None = None
tbank_payment_id: int | None = None
cdek_order_uuid: str | None = None
cse_order_number: str | None = None
cdek_waybill_uuid: str | None = None
cdek_waybill_url: str | None = None
payment_email_sent_at: object | None = None
@@ -216,7 +218,7 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
providers=[],
payment_adapter=payment_adapter,
order_repository=order_repository,
order_registration_adapter=cdek_adapter,
order_registration_adapters={"cdek": cdek_adapter},
)
notification = _make_notification()
@@ -240,7 +242,7 @@ def test_duplicate_confirmed_notification_does_not_call_cdek() -> None:
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=cdek_adapter,
order_registration_adapters={"cdek": cdek_adapter},
)
result = asyncio.run(
@@ -259,7 +261,7 @@ def test_non_confirmed_notification_acknowledges_without_cdek() -> None:
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=cdek_adapter,
order_registration_adapters={"cdek": cdek_adapter},
)
result = asyncio.run(
@@ -282,7 +284,7 @@ def test_invalid_token_does_not_access_repository_or_cdek() -> None:
verify_error=TBankPaymentNotificationTokenError("bad token")
),
order_repository=order_repository,
order_registration_adapter=cdek_adapter,
order_registration_adapters={"cdek": cdek_adapter},
)
with pytest.raises(InvalidTBankPaymentNotificationError):
@@ -298,7 +300,7 @@ def test_missing_order_returns_processing_error_without_cdek() -> None:
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(),
order_registration_adapter=cdek_adapter,
order_registration_adapters={"cdek": cdek_adapter},
)
with pytest.raises(TBankPaymentNotificationProcessingError):
@@ -313,7 +315,7 @@ def test_cdek_registration_failure_returns_processing_error() -> None:
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[StoredOrder()]),
order_registration_adapter=cdek_adapter,
order_registration_adapters={"cdek": cdek_adapter},
)
with pytest.raises(TBankPaymentNotificationProcessingError):
@@ -346,7 +348,7 @@ def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id()
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=order_repository,
order_registration_adapter=cdek_adapter,
order_registration_adapters={"cdek": cdek_adapter},
)
notification = _make_notification()
@@ -372,7 +374,7 @@ def test_confirmed_notification_sends_payment_confirmation_email() -> None:
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=StubCDEKOrderAdapter(),
order_registration_adapters={"cdek": StubCDEKOrderAdapter()},
email_sender=email_sender,
)
@@ -394,7 +396,7 @@ def test_non_confirmed_notification_does_not_send_email() -> None:
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=StubCDEKOrderAdapter(),
order_registration_adapters={"cdek": StubCDEKOrderAdapter()},
email_sender=email_sender,
)
@@ -421,7 +423,7 @@ def test_duplicate_notification_does_not_resend_payment_email() -> None:
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=StubCDEKOrderAdapter(),
order_registration_adapters={"cdek": StubCDEKOrderAdapter()},
email_sender=email_sender,
)
@@ -440,7 +442,7 @@ def test_email_failure_does_not_break_notification_handling() -> None:
providers=[],
payment_adapter=StubPaymentAdapter(),
order_repository=StubOrderRepository(orders=[order]),
order_registration_adapter=StubCDEKOrderAdapter(),
order_registration_adapters={"cdek": StubCDEKOrderAdapter()},
email_sender=email_sender,
)