Добавлен провайдер доставки CSE: SOAP-адаптер (Calc + SaveDocuments), маршрутизация init-payment по провайдеру, обобщение tariff_code до строки, география CSE в cities_map
Deploy / deploy (push) Failing after 52s
Deploy / deploy (push) Failing after 52s
This commit is contained in:
@@ -22,7 +22,7 @@ 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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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):
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()))
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user