Рефактор
Deploy / deploy (push) Failing after 14m35s

This commit is contained in:
Раис Юсупалиев
2026-06-26 19:29:11 +03:00
parent c6c37640fd
commit 3f7c6dc631
37 changed files with 802 additions and 461 deletions
+7
View File
@@ -110,6 +110,13 @@ jobs:
render docker-compose.template.yml docker-compose.rendered.yml render docker-compose.template.yml docker-compose.rendered.yml
render otel-collector-config.template.yaml otel-collector-config.rendered.yaml render otel-collector-config.template.yaml otel-collector-config.rendered.yaml
IMG=gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
docker pull "$IMG"
docker run --rm \
-v "$PWD/config.rendered.yaml:/config.yaml:ro" \
"$IMG" \
uv run --no-sync python -c "from app.config import get_settings; get_settings(); print('config ok')"
scp -i ~/.ssh/deploy_key config.rendered.yaml \ scp -i ~/.ssh/deploy_key config.rendered.yaml \
deploy@194.58.121.203:$DEPLOY_DIR/config.yaml deploy@194.58.121.203:$DEPLOY_DIR/config.yaml
scp -i ~/.ssh/deploy_key docker-compose.rendered.yml \ scp -i ~/.ssh/deploy_key docker-compose.rendered.yml \
@@ -0,0 +1,115 @@
"""Generalize provider order state columns."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260626_039"
down_revision: str | None = "20260530_038"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column("provider_order_id", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_order_status", sa.String(length=64), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_waybill_id", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_waybill_url", sa.String(length=2048), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_polled_at", sa.DateTime(timezone=True), nullable=True),
)
op.execute(
"""
UPDATE orders
SET
provider_order_id = cdek_order_uuid,
provider_order_status = cdek_order_status,
provider_waybill_id = cdek_waybill_uuid,
provider_waybill_url = cdek_waybill_url,
provider_polled_at = cdek_polled_at
WHERE provider = 'cdek'
"""
)
op.execute(
"""
UPDATE orders
SET provider_order_id = cse_order_number
WHERE provider = 'cse'
"""
)
op.drop_column("orders", "cse_order_number")
op.drop_column("orders", "cdek_polled_at")
op.drop_column("orders", "cdek_order_status")
op.drop_column("orders", "cdek_waybill_url")
op.drop_column("orders", "cdek_waybill_uuid")
op.drop_column("orders", "cdek_order_uuid")
def downgrade() -> None:
op.add_column(
"orders",
sa.Column("cdek_order_uuid", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_waybill_uuid", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_waybill_url", sa.String(length=2048), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_order_status", sa.String(length=64), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_polled_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"orders",
sa.Column("cse_order_number", sa.String(length=128), nullable=True),
)
op.execute(
"""
UPDATE orders
SET
cdek_order_uuid = provider_order_id,
cdek_order_status = provider_order_status,
cdek_waybill_uuid = provider_waybill_id,
cdek_waybill_url = provider_waybill_url,
cdek_polled_at = provider_polled_at
WHERE provider = 'cdek'
"""
)
op.execute(
"""
UPDATE orders
SET cse_order_number = provider_order_id
WHERE provider = 'cse'
"""
)
op.drop_column("orders", "provider_polled_at")
op.drop_column("orders", "provider_waybill_url")
op.drop_column("orders", "provider_waybill_id")
op.drop_column("orders", "provider_order_status")
op.drop_column("orders", "provider_order_id")
+23
View File
@@ -1,7 +1,9 @@
"""Base interface for delivery providers.""" """Base interface for delivery providers."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
@@ -18,6 +20,27 @@ class DeliveryProvider(ABC):
raise NotImplementedError raise NotImplementedError
@runtime_checkable
class PaymentPriceValidationProvider(Protocol):
name: str
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None: ...
@runtime_checkable
class OrderRegistrationProvider(Protocol):
name: str
async def register_order(
self,
request: InitPaymentRequest,
order_uuid: str,
) -> object: ...
class ProviderClientError(RuntimeError): class ProviderClientError(RuntimeError):
"""Raised when a provider call fails for temporary or provider-side reasons.""" """Raised when a provider call fails for temporary or provider-side reasons."""
+12 -12
View File
@@ -33,7 +33,7 @@ from app.adapters.delivery_providers.cdek.order_mapper import (
resolve_cdek_city_code, resolve_cdek_city_code,
) )
from app.cities import cities_map from app.cities import cities_map
from app.config import AdapterConfig from app.config import CDEKDeliveryProviderConfig
from app.schemas.payment import InitPaymentRequest from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
@@ -452,28 +452,28 @@ class CDEKProvider(DeliveryProvider):
self.cache_ttl_seconds = cache_ttl_seconds self.cache_ttl_seconds = cache_ttl_seconds
@classmethod @classmethod
def from_adapter_config( def from_config(
cls, cls,
*, *,
http_client: httpx.AsyncClient, http_client: httpx.AsyncClient,
adapter_config: AdapterConfig, config: CDEKDeliveryProviderConfig,
) -> "CDEKProvider": ) -> "CDEKProvider":
auth_client = CDEKAuthClient( auth_client = CDEKAuthClient(
http_client=http_client, http_client=http_client,
base_url=adapter_config.cdek_base_url, base_url=config.base_url,
client_id=adapter_config.cdek_client_id, client_id=config.client_id,
client_secret=adapter_config.cdek_client_secret, client_secret=config.client_secret,
timeout_seconds=adapter_config.cdek_timeout_seconds, timeout_seconds=config.timeout_seconds,
) )
client = CDEKClient( client = CDEKClient(
http_client=http_client, http_client=http_client,
auth_client=auth_client, auth_client=auth_client,
base_url=adapter_config.cdek_base_url, base_url=config.base_url,
timeout_seconds=adapter_config.cdek_timeout_seconds, timeout_seconds=config.timeout_seconds,
retry_attempts=adapter_config.cdek_retry_attempts, retry_attempts=config.retry_attempts,
retry_backoff_seconds=adapter_config.cdek_retry_backoff_seconds, retry_backoff_seconds=config.retry_backoff_seconds,
) )
return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds) return cls(client=client, cache_ttl_seconds=config.cache_ttl_seconds)
async def get_prices( async def get_prices(
self, request: DeliveryCalculationRequest self, request: DeliveryCalculationRequest
+13 -13
View File
@@ -35,7 +35,7 @@ from app.adapters.delivery_providers.cse.soap import (
make_field, make_field,
parse_response, parse_response,
) )
from app.config import AdapterConfig from app.config import CSEDeliveryProviderConfig
from app.schemas.payment import InitPaymentRequest from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
@@ -212,27 +212,27 @@ class CSEProvider(DeliveryProvider):
self._delivery_types: list[tuple[str, str]] | None = None self._delivery_types: list[tuple[str, str]] | None = None
@classmethod @classmethod
def from_adapter_config( def from_config(
cls, cls,
*, *,
http_client: httpx.AsyncClient, http_client: httpx.AsyncClient,
adapter_config: AdapterConfig, config: CSEDeliveryProviderConfig,
) -> "CSEProvider": ) -> "CSEProvider":
client = CSEClient( client = CSEClient(
http_client=http_client, http_client=http_client,
base_url=adapter_config.cse_base_url, base_url=config.base_url,
login=adapter_config.cse_login, login=config.login,
password=adapter_config.cse_password, password=config.password,
registration_params=CSEOrderRegistrationParams( registration_params=CSEOrderRegistrationParams(
payer=adapter_config.cse_payer, payer=config.payer,
payment_method=adapter_config.cse_payment_method, payment_method=config.payment_method,
shipping_method=adapter_config.cse_shipping_method, shipping_method=config.shipping_method,
), ),
timeout_seconds=adapter_config.cse_timeout_seconds, timeout_seconds=config.timeout_seconds,
retry_attempts=adapter_config.cse_retry_attempts, retry_attempts=config.retry_attempts,
retry_backoff_seconds=adapter_config.cse_retry_backoff_seconds, retry_backoff_seconds=config.retry_backoff_seconds,
) )
return cls(client=client, cache_ttl_seconds=adapter_config.cse_cache_ttl_seconds) return cls(client=client, cache_ttl_seconds=config.cache_ttl_seconds)
async def get_prices( async def get_prices(
self, request: DeliveryCalculationRequest self, request: DeliveryCalculationRequest
@@ -0,0 +1,67 @@
"""Delivery provider registry and wiring."""
from dataclasses import dataclass
from typing import Mapping, Sequence
import httpx
from app.adapters.delivery_providers.base import (
DeliveryProvider,
OrderRegistrationProvider,
PaymentPriceValidationProvider,
)
from app.adapters.delivery_providers.cdek import CDEKProvider
from app.adapters.delivery_providers.cse import CSEProvider
from app.config import DeliveryProvidersConfig
@dataclass(frozen=True, slots=True)
class DeliveryProviderRegistry:
providers: Sequence[DeliveryProvider]
payment_price_validation_adapters: Mapping[str, PaymentPriceValidationProvider]
order_registration_adapters: Mapping[str, OrderRegistrationProvider]
def resolve_delivery_provider_timeout_seconds(
config: DeliveryProvidersConfig,
) -> float:
timeouts = [
provider_config.timeout_seconds
for provider_config in (config.cdek, config.cse)
if provider_config.enabled
]
return max(timeouts, default=10.0)
def build_delivery_provider_registry(
*,
http_client: httpx.AsyncClient,
config: DeliveryProvidersConfig,
) -> DeliveryProviderRegistry:
providers: list[DeliveryProvider] = []
payment_price_validation_adapters: dict[str, PaymentPriceValidationProvider] = {}
order_registration_adapters: dict[str, OrderRegistrationProvider] = {}
if config.cdek.enabled:
cdek_provider = CDEKProvider.from_config(
http_client=http_client,
config=config.cdek,
)
providers.append(cdek_provider)
payment_price_validation_adapters[cdek_provider.name] = cdek_provider
order_registration_adapters[cdek_provider.name] = cdek_provider
if config.cse.enabled:
cse_provider = CSEProvider.from_config(
http_client=http_client,
config=config.cse,
)
providers.append(cse_provider)
payment_price_validation_adapters[cse_provider.name] = cse_provider
order_registration_adapters[cse_provider.name] = cse_provider
return DeliveryProviderRegistry(
providers=tuple(providers),
payment_price_validation_adapters=payment_price_validation_adapters,
order_registration_adapters=order_registration_adapters,
)
+32 -20
View File
@@ -38,26 +38,36 @@ class RepositoryConfig(BaseModel):
price_cache_ttl_seconds: int = 900 price_cache_ttl_seconds: int = 900
class AdapterConfig(BaseModel): class DeliveryProviderBaseConfig(BaseModel):
cdek_base_url: str = "https://api.cdek.ru/v2" enabled: bool = True
cdek_client_id: str = "" timeout_seconds: float = Field(default=10.0, gt=0)
cdek_client_secret: str = "" retry_attempts: int = Field(default=2, ge=0)
cdek_retry_attempts: int = Field(default=2, ge=0) retry_backoff_seconds: float = Field(default=0.2, ge=0)
cdek_retry_backoff_seconds: float = Field(default=0.2, ge=0) cache_ttl_seconds: int = Field(default=900, gt=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" class CDEKDeliveryProviderConfig(DeliveryProviderBaseConfig):
cse_login: str = "" base_url: str = "https://api.cdek.ru/v2"
cse_password: str = "" client_id: str = ""
cse_retry_attempts: int = Field(default=2, ge=0) client_secret: str = ""
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) class CSEDeliveryProviderConfig(DeliveryProviderBaseConfig):
base_url: str = "https://web.cse.ru/1c/ws/Web1C.1cws"
login: str = ""
password: str = ""
# Contract-specific required parameters for SaveDocuments (order registration). # Contract-specific required parameters for SaveDocuments (order registration).
# Urgency is not here: it comes from the tariff selected by the client. # Urgency is not here: it comes from the tariff selected by the client.
cse_payer: str = "" payer: str = ""
cse_payment_method: str = "" payment_method: str = ""
cse_shipping_method: str = "" shipping_method: str = ""
class DeliveryProvidersConfig(BaseModel):
cdek: CDEKDeliveryProviderConfig = Field(
default_factory=CDEKDeliveryProviderConfig
)
cse: CSEDeliveryProviderConfig = Field(default_factory=CSEDeliveryProviderConfig)
class TBankPaymentAuthConfig(BaseModel): class TBankPaymentAuthConfig(BaseModel):
@@ -157,7 +167,9 @@ class Settings(BaseSettings):
service: ServiceConfig = Field(default_factory=ServiceConfig) service: ServiceConfig = Field(default_factory=ServiceConfig)
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig) business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
repository: RepositoryConfig = Field(default_factory=RepositoryConfig) repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
adapter: AdapterConfig = Field(default_factory=AdapterConfig) delivery_providers: DeliveryProvidersConfig = Field(
default_factory=DeliveryProvidersConfig
)
tbank_payment: TBankPaymentConfig tbank_payment: TBankPaymentConfig
postgres: PostgresConfig postgres: PostgresConfig
address_suggestions: AddressSuggestionsConfig = Field( address_suggestions: AddressSuggestionsConfig = Field(
@@ -195,7 +207,7 @@ class _RequiredYamlSections(BaseModel):
service: dict[str, Any] service: dict[str, Any]
business_logic: dict[str, Any] business_logic: dict[str, Any]
repository: dict[str, Any] repository: dict[str, Any]
adapter: dict[str, Any] delivery_providers: dict[str, Any]
tbank_payment: dict[str, Any] tbank_payment: dict[str, Any]
postgres: dict[str, Any] postgres: dict[str, Any]
address_suggestions: dict[str, Any] address_suggestions: dict[str, Any]
+16 -19
View File
@@ -10,8 +10,10 @@ from app.adapters.address_suggestions.tomtom import TomTomAddressSuggestionProvi
from app.adapters.address_suggestions.yandex_geosuggest import ( from app.adapters.address_suggestions.yandex_geosuggest import (
YandexGeosuggestAddressSuggestionProvider, YandexGeosuggestAddressSuggestionProvider,
) )
from app.adapters.delivery_providers.cdek import CDEKProvider from app.adapters.delivery_providers.registry import (
from app.adapters.delivery_providers.cse import CSEProvider build_delivery_provider_registry,
resolve_delivery_provider_timeout_seconds,
)
from app.adapters.email import SMTPEmailSender from app.adapters.email import SMTPEmailSender
from app.adapters.tbank import TBankAdapter from app.adapters.tbank import TBankAdapter
from app.config import Settings from app.config import Settings
@@ -49,15 +51,13 @@ _AGGREGATOR_SERVICE_STATE_KEY = "aggregator_service"
def _build_aggregator_service(settings: Settings) -> AggregatorService: def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client = build_controller_http_client( http_client = build_controller_http_client(
timeout_seconds=settings.adapter.cdek_timeout_seconds timeout_seconds=resolve_delivery_provider_timeout_seconds(
settings.delivery_providers
)
) )
cdek_provider = CDEKProvider.from_adapter_config( delivery_provider_registry = build_delivery_provider_registry(
http_client=http_client, http_client=http_client,
adapter_config=settings.adapter, config=settings.delivery_providers,
)
cse_provider = CSEProvider.from_adapter_config(
http_client=http_client,
adapter_config=settings.adapter,
) )
payment_adapter = TBankAdapter.from_config( payment_adapter = TBankAdapter.from_config(
http_client=http_client, http_client=http_client,
@@ -75,7 +75,6 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client, http_client=http_client,
config=settings.address_suggestions.tomtom, config=settings.address_suggestions.tomtom,
) )
providers = (cdek_provider, cse_provider)
cache = PriceCache.from_repository_config( cache = PriceCache.from_repository_config(
settings.repository, settings.repository,
metrics=get_cache_metrics(), metrics=get_cache_metrics(),
@@ -93,18 +92,16 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
timeout_seconds=settings.email.timeout_seconds, timeout_seconds=settings.email.timeout_seconds,
) )
service = AggregatorService( service = AggregatorService(
providers=providers, providers=delivery_provider_registry.providers,
cache=cache, cache=cache,
payment_adapter=payment_adapter, payment_adapter=payment_adapter,
payment_price_validation_adapters={ payment_price_validation_adapters=(
cdek_provider.name: cdek_provider, delivery_provider_registry.payment_price_validation_adapters
cse_provider.name: cse_provider, ),
},
order_repository=order_repository, order_repository=order_repository,
order_registration_adapters={ order_registration_adapters=(
cdek_provider.name: cdek_provider, delivery_provider_registry.order_registration_adapters
cse_provider.name: cse_provider, ),
},
email_sender=email_sender, email_sender=email_sender,
address_suggestion_providers=( address_suggestion_providers=(
dadata_provider, dadata_provider,
+7 -6
View File
@@ -45,12 +45,13 @@ class Order(Base):
) )
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True) payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, 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) provider_order_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
cse_order_number: Mapped[str | None] = mapped_column(String(128), nullable=True) provider_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
cdek_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True) provider_waybill_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_waybill_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True) provider_waybill_url: Mapped[str | None] = mapped_column(
cdek_waybill_url: Mapped[str | None] = mapped_column(String(2048), nullable=True) String(2048), nullable=True
cdek_polled_at: Mapped[datetime | None] = mapped_column( )
provider_polled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=True, nullable=True,
) )
+18 -30
View File
@@ -75,31 +75,17 @@ class OrderRepository:
await session.flush() await session.flush()
return order return order
async def mark_cdek_order_registered( async def mark_provider_order_registered(
self, self,
session: AsyncSession, session: AsyncSession,
order_uuid: str, order_uuid: str,
cdek_order_uuid: str, provider_order_id: str,
) -> Order | None: ) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid) order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None: if order is None:
return None return None
order.cdek_order_uuid = cdek_order_uuid order.provider_order_id = provider_order_id
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() await session.flush()
return order return order
@@ -112,14 +98,15 @@ class OrderRepository:
statement = ( statement = (
select(Order) select(Order)
.where( .where(
Order.cdek_order_uuid.is_not(None), Order.provider == "cdek",
Order.cdek_waybill_url.is_(None), Order.provider_order_id.is_not(None),
Order.provider_waybill_url.is_(None),
( (
Order.cdek_order_status.is_(None) Order.provider_order_status.is_(None)
| Order.cdek_order_status.not_in(TERMINAL_ORDER_STATUSES) | Order.provider_order_status.not_in(TERMINAL_ORDER_STATUSES)
), ),
) )
.order_by(Order.cdek_polled_at.asc().nulls_first()) .order_by(Order.provider_polled_at.asc().nulls_first())
.limit(limit) .limit(limit)
.with_for_update(skip_locked=True) .with_for_update(skip_locked=True)
) )
@@ -139,10 +126,10 @@ class OrderRepository:
if order is None: if order is None:
return None return None
order.cdek_order_status = order_status order.provider_order_status = order_status
if waybill_uuid is not None and order.cdek_waybill_uuid is None: if waybill_uuid is not None and order.provider_waybill_id is None:
order.cdek_waybill_uuid = waybill_uuid order.provider_waybill_id = waybill_uuid
order.cdek_polled_at = polled_at order.provider_polled_at = polled_at
await session.flush() await session.flush()
return order return order
@@ -158,9 +145,9 @@ class OrderRepository:
if order is None: if order is None:
return None return None
if waybill_url is not None and order.cdek_waybill_url is None: if waybill_url is not None and order.provider_waybill_url is None:
order.cdek_waybill_url = waybill_url order.provider_waybill_url = waybill_url
order.cdek_polled_at = polled_at order.provider_polled_at = polled_at
await session.flush() await session.flush()
return order return order
@@ -189,7 +176,8 @@ class OrderRepository:
statement = ( statement = (
select(Order) select(Order)
.where( .where(
Order.cdek_waybill_url.is_not(None), Order.provider == "cdek",
Order.provider_waybill_url.is_not(None),
Order.waybill_email_sent_at.is_(None), Order.waybill_email_sent_at.is_(None),
) )
.order_by(Order.created_at.asc()) .order_by(Order.created_at.asc())
+11 -29
View File
@@ -141,18 +141,11 @@ class OrderRepositoryProtocol(Protocol):
payment_id: int, payment_id: int,
) -> object | None: ... ) -> object | None: ...
async def mark_cdek_order_registered( async def mark_provider_order_registered(
self, self,
session: object, session: object,
order_uuid: str, order_uuid: str,
cdek_order_uuid: str, provider_order_id: str,
) -> object | None: ...
async def mark_cse_order_registered(
self,
session: object,
order_uuid: str,
cse_order_number: str,
) -> object | None: ... ) -> object | None: ...
@@ -357,7 +350,7 @@ class AggregatorService:
error=str(exc), error=str(exc),
) )
raise InvalidInitPaymentRequestError( raise InvalidInitPaymentRequestError(
"Payment init request is invalid for CDEK price validation." "Payment init request is invalid for provider price validation."
) from exc ) from exc
except ProviderClientError as exc: except ProviderClientError as exc:
logger.warning( logger.warning(
@@ -389,7 +382,7 @@ class AggregatorService:
requested_price_kopecks=requested_price, requested_price_kopecks=requested_price,
) )
raise InvalidInitPaymentRequestError( raise InvalidInitPaymentRequestError(
"CDEK did not return the requested tariff for payment validation." "Provider did not return the requested tariff for payment validation."
) )
expected_amount_kopecks = calculate_expected_payment_amount_kopecks( expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
@@ -411,7 +404,7 @@ class AggregatorService:
provider_price=str(getattr(provider_price, "price", None)), provider_price=str(getattr(provider_price, "price", None)),
) )
raise InvalidInitPaymentRequestError( raise InvalidInitPaymentRequestError(
"Payment amount does not match CDEK validated delivery price." "Payment amount does not match provider validated delivery price."
) )
async def handle_tbank_payment_notification( async def handle_tbank_payment_notification(
@@ -593,11 +586,7 @@ class AggregatorService:
@staticmethod @staticmethod
def _has_existing_registration(order: object, provider: str) -> bool: def _has_existing_registration(order: object, provider: str) -> bool:
if provider == "cdek": return bool(getattr(order, "provider_order_id", None))
return bool(getattr(order, "cdek_order_uuid", None))
if provider == "cse":
return bool(getattr(order, "cse_order_number", None))
return False
@staticmethod @staticmethod
def _extract_registration_id(result: object) -> str: def _extract_registration_id(result: object) -> str:
@@ -646,18 +635,11 @@ class AggregatorService:
registration_id = self._extract_registration_id(result) registration_id = self._extract_registration_id(result)
try: try:
async with self._order_repository.session() as session: async with self._order_repository.session() as session:
if provider == "cse": order = await self._order_repository.mark_provider_order_registered(
order = await self._order_repository.mark_cse_order_registered( session,
session, order_uuid,
order_uuid, registration_id,
registration_id, )
)
else:
order = await self._order_repository.mark_cdek_order_registered(
session,
order_uuid,
registration_id,
)
if order is None: if order is None:
logger.warning( logger.warning(
"order_registration_order_not_found", "order_registration_order_not_found",
+2 -2
View File
@@ -41,7 +41,7 @@ class EmailSenderProtocol(Protocol):
class OrderRecord(Protocol): class OrderRecord(Protocol):
order_uuid: str order_uuid: str
account_email: str account_email: str
cdek_waybill_url: str | None provider_waybill_url: str | None
class WaybillEmailSenderRepositoryProtocol(Protocol): class WaybillEmailSenderRepositoryProtocol(Protocol):
@@ -133,7 +133,7 @@ class WaybillEmailSenderService:
continue continue
async def _handle_order(self, session: object, order: OrderRecord) -> None: async def _handle_order(self, session: object, order: OrderRecord) -> None:
waybill_url = order.cdek_waybill_url waybill_url = order.provider_waybill_url
if waybill_url is None: if waybill_url is None:
return return
+16 -14
View File
@@ -27,8 +27,8 @@ class CDEKWaybillInfoAdapterProtocol(Protocol):
class OrderRecord(Protocol): class OrderRecord(Protocol):
order_uuid: str order_uuid: str
cdek_order_uuid: str | None provider_order_id: str | None
cdek_waybill_uuid: str | None provider_waybill_id: str | None
class WaybillPollerRepositoryProtocol(Protocol): class WaybillPollerRepositoryProtocol(Protocol):
@@ -97,8 +97,8 @@ class WaybillPollerService:
logger.exception( logger.exception(
"waybill_poll_order_failed", "waybill_poll_order_failed",
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
cdek_order_uuid=order.cdek_order_uuid, provider_order_id=order.provider_order_id,
cdek_waybill_uuid=order.cdek_waybill_uuid, provider_waybill_id=order.provider_waybill_id,
) )
return PollBatchSummary( return PollBatchSummary(
processed=len(orders), processed=len(orders),
@@ -133,11 +133,11 @@ class WaybillPollerService:
async def _handle_order(self, session: object, order: OrderRecord) -> None: async def _handle_order(self, session: object, order: OrderRecord) -> None:
polled_at = self._datetime_now() polled_at = self._datetime_now()
if order.cdek_waybill_uuid is None: if order.provider_waybill_id is None:
cdek_order_uuid = order.cdek_order_uuid provider_order_id = order.provider_order_id
if cdek_order_uuid is None: if provider_order_id is None:
return return
info = await self._order_info_adapter.get_order(cdek_order_uuid) info = await self._order_info_adapter.get_order(provider_order_id)
await self._repository.record_order_poll( await self._repository.record_order_poll(
session, session,
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
@@ -148,13 +148,15 @@ class WaybillPollerService:
logger.info( logger.info(
"waybill_poll_order_result", "waybill_poll_order_result",
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
cdek_order_uuid=cdek_order_uuid, provider_order_id=provider_order_id,
cdek_order_status=info.status_code, provider_order_status=info.status_code,
cdek_waybill_uuid=info.waybill_uuid, provider_waybill_id=info.waybill_uuid,
) )
return return
waybill = await self._waybill_info_adapter.get_waybill(order.cdek_waybill_uuid) waybill = await self._waybill_info_adapter.get_waybill(
order.provider_waybill_id
)
await self._repository.record_waybill_poll( await self._repository.record_waybill_poll(
session, session,
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
@@ -164,6 +166,6 @@ class WaybillPollerService:
logger.info( logger.info(
"waybill_poll_waybill_result", "waybill_poll_waybill_result",
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
cdek_waybill_uuid=order.cdek_waybill_uuid, provider_waybill_id=order.provider_waybill_id,
cdek_waybill_url=waybill.url, provider_waybill_url=waybill.url,
) )
+10 -9
View File
@@ -22,21 +22,22 @@ logger = structlog.get_logger(__name__)
async def _run(settings: Settings, stop_event: asyncio.Event) -> None: async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds) cdek_config = settings.delivery_providers.cdek
http_client = httpx.AsyncClient(timeout=cdek_config.timeout_seconds)
auth_client = CDEKAuthClient( auth_client = CDEKAuthClient(
http_client=http_client, http_client=http_client,
base_url=settings.adapter.cdek_base_url, base_url=cdek_config.base_url,
client_id=settings.adapter.cdek_client_id, client_id=cdek_config.client_id,
client_secret=settings.adapter.cdek_client_secret, client_secret=cdek_config.client_secret,
timeout_seconds=settings.adapter.cdek_timeout_seconds, timeout_seconds=cdek_config.timeout_seconds,
) )
cdek_client = CDEKClient( cdek_client = CDEKClient(
http_client=http_client, http_client=http_client,
auth_client=auth_client, auth_client=auth_client,
base_url=settings.adapter.cdek_base_url, base_url=cdek_config.base_url,
timeout_seconds=settings.adapter.cdek_timeout_seconds, timeout_seconds=cdek_config.timeout_seconds,
retry_attempts=settings.adapter.cdek_retry_attempts, retry_attempts=cdek_config.retry_attempts,
retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds, retry_backoff_seconds=cdek_config.retry_backoff_seconds,
) )
email_sender = SMTPEmailSender( email_sender = SMTPEmailSender(
smtp_host=settings.email.smtp_host, smtp_host=settings.email.smtp_host,
+10 -9
View File
@@ -21,21 +21,22 @@ logger = structlog.get_logger(__name__)
async def _run(settings: Settings, stop_event: asyncio.Event) -> None: async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds) cdek_config = settings.delivery_providers.cdek
http_client = httpx.AsyncClient(timeout=cdek_config.timeout_seconds)
auth_client = CDEKAuthClient( auth_client = CDEKAuthClient(
http_client=http_client, http_client=http_client,
base_url=settings.adapter.cdek_base_url, base_url=cdek_config.base_url,
client_id=settings.adapter.cdek_client_id, client_id=cdek_config.client_id,
client_secret=settings.adapter.cdek_client_secret, client_secret=cdek_config.client_secret,
timeout_seconds=settings.adapter.cdek_timeout_seconds, timeout_seconds=cdek_config.timeout_seconds,
) )
cdek_client = CDEKClient( cdek_client = CDEKClient(
http_client=http_client, http_client=http_client,
auth_client=auth_client, auth_client=auth_client,
base_url=settings.adapter.cdek_base_url, base_url=cdek_config.base_url,
timeout_seconds=settings.adapter.cdek_timeout_seconds, timeout_seconds=cdek_config.timeout_seconds,
retry_attempts=settings.adapter.cdek_retry_attempts, retry_attempts=cdek_config.retry_attempts,
retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds, retry_backoff_seconds=cdek_config.retry_backoff_seconds,
) )
engine = create_postgres_engine(settings.postgres) engine = create_postgres_engine(settings.postgres)
session_factory = create_postgres_session_factory(engine) session_factory = create_postgres_session_factory(engine)
+22 -18
View File
@@ -14,24 +14,28 @@ repository:
redis_dsn: "redis://redis:6379/0" redis_dsn: "redis://redis:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: delivery_providers:
cdek_base_url: "https://api.edu.cdek.ru/v2" cdek:
cdek_client_id: "${CDEK_CLIENT_ID}" enabled: true
cdek_client_secret: "${CDEK_CLIENT_SECRET}" base_url: "https://api.edu.cdek.ru/v2"
cdek_retry_attempts: 2 client_id: "${CDEK_CLIENT_ID}"
cdek_retry_backoff_seconds: 0.2 client_secret: "${CDEK_CLIENT_SECRET}"
cdek_timeout_seconds: 10.0 retry_attempts: 2
cdek_cache_ttl_seconds: 900 retry_backoff_seconds: 0.2
cse_base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws" timeout_seconds: 10.0
cse_login: "${CSE_LOGIN}" cache_ttl_seconds: 900
cse_password: "${CSE_PASSWORD}" cse:
cse_retry_attempts: 2 enabled: true
cse_retry_backoff_seconds: 0.2 base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
cse_timeout_seconds: 10.0 login: "${CSE_LOGIN}"
cse_cache_ttl_seconds: 900 password: "${CSE_PASSWORD}"
cse_payer: "0" # Заказчик retry_attempts: 2
cse_payment_method: "1" # Безналичный расчёт retry_backoff_seconds: 0.2
cse_shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd" # авто timeout_seconds: 10.0
cache_ttl_seconds: 900
payer: "0" # Заказчик
payment_method: "1" # Безналичный расчёт
shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd" # авто
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
+22 -18
View File
@@ -14,24 +14,28 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: delivery_providers:
cdek_base_url: "https://api.cdek.ru/v2" cdek:
cdek_client_id: "test-client-id" enabled: true
cdek_client_secret: "test-client-secret" base_url: "https://api.cdek.ru/v2"
cdek_retry_attempts: 2 client_id: "test-client-id"
cdek_retry_backoff_seconds: 0.2 client_secret: "test-client-secret"
cdek_timeout_seconds: 10.0 retry_attempts: 2
cdek_cache_ttl_seconds: 900 retry_backoff_seconds: 0.2
cse_base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws" timeout_seconds: 10.0
cse_login: "test" cache_ttl_seconds: 900
cse_password: "2016" cse:
cse_retry_attempts: 2 enabled: true
cse_retry_backoff_seconds: 0.2 base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
cse_timeout_seconds: 10.0 login: "test"
cse_cache_ttl_seconds: 900 password: "2016"
cse_payer: "0" retry_attempts: 2
cse_payment_method: "1" retry_backoff_seconds: 0.2
cse_shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd" timeout_seconds: 10.0
cache_ttl_seconds: 900
payer: "0"
payment_method: "1"
shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd"
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -13,7 +13,7 @@ from app.adapters.delivery_providers.cdek.client import (
) )
from app.cities import cities_map from app.cities import cities_map
from app import config as config_module from app import config as config_module
from app.config import AdapterConfig, Settings from app.config import CDEKDeliveryProviderConfig, Settings
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
@@ -287,14 +287,17 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: delivery_providers:
cdek_base_url: "https://api.cdek.test/v2" cdek:
cdek_client_id: "yaml-id" base_url: "https://api.cdek.test/v2"
cdek_client_secret: "yaml-secret" client_id: "yaml-id"
cdek_retry_attempts: 0 client_secret: "yaml-secret"
cdek_retry_backoff_seconds: 0.1 retry_attempts: 0
cdek_timeout_seconds: 7.5 retry_backoff_seconds: 0.1
cdek_cache_ttl_seconds: 777 timeout_seconds: 7.5
cache_ttl_seconds: 777
cse:
enabled: false
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -323,9 +326,9 @@ email:
monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file)) monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file))
settings = Settings() settings = Settings()
http_client = RecordingHTTPClient() http_client = RecordingHTTPClient()
provider = CDEKProvider.from_adapter_config( provider = CDEKProvider.from_config(
http_client=http_client, # type: ignore[arg-type] http_client=http_client, # type: ignore[arg-type]
adapter_config=settings.adapter, config=settings.delivery_providers.cdek,
) )
result = asyncio.run(provider.get_prices(_make_request())) result = asyncio.run(provider.get_prices(_make_request()))
@@ -349,14 +352,14 @@ email:
def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None: def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
adapter_config = AdapterConfig( config = CDEKDeliveryProviderConfig(
cdek_client_id="default-id", client_id="default-id",
cdek_client_secret="default-secret", client_secret="default-secret",
) )
http_client = RecordingHTTPClient() http_client = RecordingHTTPClient()
provider = CDEKProvider.from_adapter_config( provider = CDEKProvider.from_config(
http_client=http_client, # type: ignore[arg-type] http_client=http_client, # type: ignore[arg-type]
adapter_config=adapter_config, config=config,
) )
result = asyncio.run(provider.get_prices(_make_request())) result = asyncio.run(provider.get_prices(_make_request()))
@@ -0,0 +1,50 @@
import asyncio
import httpx
from app.adapters.delivery_providers.registry import (
build_delivery_provider_registry,
resolve_delivery_provider_timeout_seconds,
)
from app.config import (
CDEKDeliveryProviderConfig,
CSEDeliveryProviderConfig,
DeliveryProvidersConfig,
)
def test_registry_builds_enabled_provider_capability_maps() -> None:
config = DeliveryProvidersConfig(
cdek=CDEKDeliveryProviderConfig(
base_url="https://cdek.test/v2",
client_id="id",
client_secret="secret",
cache_ttl_seconds=111,
),
cse=CSEDeliveryProviderConfig(
enabled=False,
),
)
http_client = httpx.AsyncClient()
try:
registry = build_delivery_provider_registry(
http_client=http_client,
config=config,
)
finally:
asyncio.run(http_client.aclose())
assert [provider.name for provider in registry.providers] == ["cdek"]
assert sorted(registry.payment_price_validation_adapters) == ["cdek"]
assert sorted(registry.order_registration_adapters) == ["cdek"]
assert registry.providers[0].cache_ttl_seconds == 111
def test_resolve_delivery_provider_timeout_uses_max_enabled_timeout() -> None:
config = DeliveryProvidersConfig(
cdek=CDEKDeliveryProviderConfig(timeout_seconds=3.0),
cse=CSEDeliveryProviderConfig(timeout_seconds=7.5),
)
assert resolve_delivery_provider_timeout_seconds(config) == 7.5
+6 -3
View File
@@ -11,9 +11,12 @@ business_logic:
repository: repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
adapter: delivery_providers:
cdek_client_id: "yaml-id" cdek:
cdek_client_secret: "yaml-secret" client_id: "yaml-id"
client_secret: "yaml-secret"
cse:
enabled: false
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -14,14 +14,17 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: delivery_providers:
cdek_base_url: "https://api.cdek.ru/v2" cdek:
cdek_client_id: "test-client-id" base_url: "https://api.cdek.ru/v2"
cdek_client_secret: "test-client-secret" client_id: "test-client-id"
cdek_retry_attempts: 2 client_secret: "test-client-secret"
cdek_retry_backoff_seconds: 0.2 retry_attempts: 2
cdek_timeout_seconds: 10.0 retry_backoff_seconds: 0.2
cdek_cache_ttl_seconds: 900 timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -14,14 +14,17 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: delivery_providers:
cdek_base_url: "https://api.cdek.ru/v2" cdek:
cdek_client_id: "test-client-id" base_url: "https://api.cdek.ru/v2"
cdek_client_secret: "test-client-secret" client_id: "test-client-id"
cdek_retry_attempts: 2 client_secret: "test-client-secret"
cdek_retry_backoff_seconds: 0.2 retry_attempts: 2
cdek_timeout_seconds: 10.0 retry_backoff_seconds: 0.2
cdek_cache_ttl_seconds: 900 timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -14,14 +14,17 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: delivery_providers:
cdek_base_url: "https://api.cdek.ru/v2" cdek:
cdek_client_id: "test-client-id" base_url: "https://api.cdek.ru/v2"
cdek_client_secret: "test-client-secret" client_id: "test-client-id"
cdek_retry_attempts: 2 client_secret: "test-client-secret"
cdek_retry_backoff_seconds: 0.2 retry_attempts: 2
cdek_timeout_seconds: 10.0 retry_backoff_seconds: 0.2
cdek_cache_ttl_seconds: 900 timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -14,14 +14,17 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: delivery_providers:
cdek_base_url: "https://api.cdek.ru/v2" cdek:
cdek_client_id: "test-client-id" base_url: "https://api.cdek.ru/v2"
cdek_client_secret: "test-client-secret" client_id: "test-client-id"
cdek_retry_attempts: 2 client_secret: "test-client-secret"
cdek_retry_backoff_seconds: 0.2 retry_attempts: 2
cdek_timeout_seconds: 10.0 retry_backoff_seconds: 0.2
cdek_cache_ttl_seconds: 900 timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -13,14 +13,17 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: delivery_providers:
cdek_base_url: "https://api.cdek.ru/v2" cdek:
cdek_client_id: "test-client-id" base_url: "https://api.cdek.ru/v2"
cdek_client_secret: "test-client-secret" client_id: "test-client-id"
cdek_retry_attempts: 2 client_secret: "test-client-secret"
cdek_retry_backoff_seconds: 0.2 retry_attempts: 2
cdek_timeout_seconds: 10.0 retry_backoff_seconds: 0.2
cdek_cache_ttl_seconds: 900 timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -11,9 +11,12 @@ business_logic:
repository: repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
adapter: delivery_providers:
cdek_client_id: "test-id" cdek:
cdek_client_secret: "test-secret" client_id: "test-id"
client_secret: "test-secret"
cse:
enabled: false
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
+28 -10
View File
@@ -132,7 +132,7 @@ business_logic:
repository: {{}} repository: {{}}
adapter: {{}} delivery_providers: {{}}
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -174,13 +174,31 @@ def test_configuration_sections_are_loaded_from_yaml_file(
assert settings.business_logic.provider_price_multiplier == Decimal("1.0") assert settings.business_logic.provider_price_multiplier == Decimal("1.0")
assert settings.repository.redis_dsn == "redis://localhost:6379/0" assert settings.repository.redis_dsn == "redis://localhost:6379/0"
assert settings.repository.price_cache_ttl_seconds == 900 assert settings.repository.price_cache_ttl_seconds == 900
assert settings.adapter.cdek_base_url == "https://api.cdek.ru/v2" assert settings.delivery_providers.cdek.enabled is True
assert settings.adapter.cdek_client_id == "test-client-id" assert settings.delivery_providers.cdek.base_url == "https://api.cdek.ru/v2"
assert settings.adapter.cdek_client_secret == "test-client-secret" assert settings.delivery_providers.cdek.client_id == "test-client-id"
assert settings.adapter.cdek_retry_attempts == 2 assert settings.delivery_providers.cdek.client_secret == "test-client-secret"
assert settings.adapter.cdek_retry_backoff_seconds == 0.2 assert settings.delivery_providers.cdek.retry_attempts == 2
assert settings.adapter.cdek_timeout_seconds == 10.0 assert settings.delivery_providers.cdek.retry_backoff_seconds == 0.2
assert settings.adapter.cdek_cache_ttl_seconds == 900 assert settings.delivery_providers.cdek.timeout_seconds == 10.0
assert settings.delivery_providers.cdek.cache_ttl_seconds == 900
assert settings.delivery_providers.cse.enabled is True
assert (
settings.delivery_providers.cse.base_url
== "http://lk-test.cse.ru/1c/ws/web1c.1cws"
)
assert settings.delivery_providers.cse.login == "test"
assert settings.delivery_providers.cse.password == "2016"
assert settings.delivery_providers.cse.retry_attempts == 2
assert settings.delivery_providers.cse.retry_backoff_seconds == 0.2
assert settings.delivery_providers.cse.timeout_seconds == 10.0
assert settings.delivery_providers.cse.cache_ttl_seconds == 900
assert settings.delivery_providers.cse.payer == "0"
assert settings.delivery_providers.cse.payment_method == "1"
assert (
settings.delivery_providers.cse.shipping_method
== "5052d0b3-5ea3-46f2-823f-1472686a51dd"
)
assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init" assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init"
assert ( assert (
settings.tbank_payment.notification_url settings.tbank_payment.notification_url
@@ -233,7 +251,7 @@ def test_get_settings_fails_when_required_yaml_section_is_missing(
get_settings() get_settings()
locations = {tuple(item["loc"]) for item in error.value.errors()} locations = {tuple(item["loc"]) for item in error.value.errors()}
assert ("adapter",) in locations assert ("delivery_providers",) in locations
get_settings.cache_clear() get_settings.cache_clear()
@@ -287,7 +305,7 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
assert settings.service.provider_timeout_seconds == 17.0 assert settings.service.provider_timeout_seconds == 17.0
assert settings.controller.api_prefix == "/from-config-test-yaml" assert settings.controller.api_prefix == "/from-config-test-yaml"
assert settings.business_logic.provider_price_multiplier == Decimal("1.25") assert settings.business_logic.provider_price_multiplier == Decimal("1.25")
assert settings.adapter.cdek_client_id == "test-id" assert settings.delivery_providers.cdek.client_id == "test-id"
assert settings.tbank_payment.auth.terminal_key == "override-terminal-key" assert settings.tbank_payment.auth.terminal_key == "override-terminal-key"
assert settings.tbank_payment.auth.password == "override-password" assert settings.tbank_payment.auth.password == "override-password"
assert ( assert (
@@ -5,6 +5,7 @@ import pytest
from app.controllers.v1 import delivery as delivery_controller from app.controllers.v1 import delivery as delivery_controller
from app.controllers.v1.delivery import get_aggregator_service from app.controllers.v1.delivery import get_aggregator_service
from app.adapters.delivery_providers.registry import DeliveryProviderRegistry
from app.main import create_app from app.main import create_app
from app.schemas.request import AddressSuggestRequest, SuggestAddressRequest from app.schemas.request import AddressSuggestRequest, SuggestAddressRequest
from app.schemas.response import AddressSuggestion from app.schemas.response import AddressSuggestion
@@ -52,16 +53,6 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
async def aclose(self) -> None: async def aclose(self) -> None:
return None return None
class StubCDEKProvider:
name = "stub-cdek"
cache_ttl_seconds = 900
@classmethod
def from_adapter_config(cls, *, http_client, adapter_config) -> "StubCDEKProvider":
assert http_client is stub_http_client
_ = adapter_config
return cls()
class StubAddressProvider: class StubAddressProvider:
def __init__(self, name: str, response: list[AddressSuggestion]) -> None: def __init__(self, name: str, response: list[AddressSuggestion]) -> None:
self.name = name self.name = name
@@ -169,12 +160,38 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
http_client_timeouts.append(timeout_seconds) http_client_timeouts.append(timeout_seconds)
return stub_http_client return stub_http_client
def fake_resolve_delivery_provider_timeout_seconds(config: object) -> float:
_ = config
return 10.0
def fake_build_delivery_provider_registry(
*,
http_client: object,
config: object,
) -> DeliveryProviderRegistry:
assert http_client is stub_http_client
_ = config
return DeliveryProviderRegistry(
providers=(),
payment_price_validation_adapters={},
order_registration_adapters={},
)
monkeypatch.setattr( monkeypatch.setattr(
delivery_controller, delivery_controller,
"build_controller_http_client", "build_controller_http_client",
fake_build_controller_http_client, fake_build_controller_http_client,
) )
monkeypatch.setattr(delivery_controller, "CDEKProvider", StubCDEKProvider) monkeypatch.setattr(
delivery_controller,
"resolve_delivery_provider_timeout_seconds",
fake_resolve_delivery_provider_timeout_seconds,
)
monkeypatch.setattr(
delivery_controller,
"build_delivery_provider_registry",
fake_build_delivery_provider_registry,
)
monkeypatch.setattr( monkeypatch.setattr(
delivery_controller, delivery_controller,
"DadataAddressSuggestionProvider", "DadataAddressSuggestionProvider",
+29 -9
View File
@@ -9,6 +9,7 @@ from app.controllers.v1.delivery import get_aggregator_service
from app.main import create_app from app.main import create_app
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity, ParcelType from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity, ParcelType
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
from app.adapters.delivery_providers.registry import DeliveryProviderRegistry
from app.services.aggregator import ( from app.services.aggregator import (
AggregatorService, AggregatorService,
AggregatorServiceError, AggregatorServiceError,
@@ -118,13 +119,6 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
), ),
] ]
class StubCDEKProvider:
@classmethod
def from_adapter_config(cls, *, http_client, adapter_config) -> StubProvider:
assert http_client is stub_http_client
_ = adapter_config
return stub_provider
class StubCache: class StubCache:
def __init__(self) -> None: def __init__(self) -> None:
self.storage: dict[str, object] = {} self.storage: dict[str, object] = {}
@@ -154,12 +148,38 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
http_client_timeouts.append(timeout_seconds) http_client_timeouts.append(timeout_seconds)
return stub_http_client return stub_http_client
def fake_resolve_delivery_provider_timeout_seconds(config: object) -> float:
_ = config
return 12.5
def fake_build_delivery_provider_registry(
*,
http_client: object,
config: object,
) -> DeliveryProviderRegistry:
assert http_client is stub_http_client
_ = config
return DeliveryProviderRegistry(
providers=(stub_provider,),
payment_price_validation_adapters={},
order_registration_adapters={},
)
monkeypatch.setattr( monkeypatch.setattr(
delivery_controller, delivery_controller,
"build_controller_http_client", "build_controller_http_client",
fake_build_controller_http_client, fake_build_controller_http_client,
) )
monkeypatch.setattr(delivery_controller, "CDEKProvider", StubCDEKProvider) monkeypatch.setattr(
delivery_controller,
"resolve_delivery_provider_timeout_seconds",
fake_resolve_delivery_provider_timeout_seconds,
)
monkeypatch.setattr(
delivery_controller,
"build_delivery_provider_registry",
fake_build_delivery_provider_registry,
)
monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache) monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache)
app = create_app() app = create_app()
@@ -199,7 +219,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
} }
] ]
assert second_response.json() == first_response.json() assert second_response.json() == first_response.json()
assert len(http_client_timeouts) == 1 assert http_client_timeouts == [12.5]
assert len(stub_provider.calls) == 1 assert len(stub_provider.calls) == 1
+11 -8
View File
@@ -186,14 +186,17 @@ repository:
redis_dsn: "redis://redis.internal:6380/5" redis_dsn: "redis://redis.internal:6380/5"
price_cache_ttl_seconds: 123 price_cache_ttl_seconds: 123
adapter: delivery_providers:
cdek_base_url: "https://api.cdek.ru/v2" cdek:
cdek_client_id: "test-client-id" base_url: "https://api.cdek.ru/v2"
cdek_client_secret: "test-client-secret" client_id: "test-client-id"
cdek_retry_attempts: 2 client_secret: "test-client-secret"
cdek_retry_backoff_seconds: 0.2 retry_attempts: 2
cdek_timeout_seconds: 10.0 retry_backoff_seconds: 0.2
cdek_cache_ttl_seconds: 900 timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
tbank_payment: tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init" init_url: "https://securepay.tinkoff.ru/v2/Init"
+85 -79
View File
@@ -69,9 +69,9 @@ def test_create_order_persists_all_required_fields() -> None:
assert persisted_order.payload == order_data.payload assert persisted_order.payload == order_data.payload
assert persisted_order.payment_status is None assert persisted_order.payment_status is None
assert persisted_order.tbank_payment_id is None assert persisted_order.tbank_payment_id is None
assert persisted_order.cdek_order_uuid is None assert persisted_order.provider_order_id is None
assert persisted_order.cdek_waybill_uuid is None assert persisted_order.provider_waybill_id is None
assert persisted_order.cdek_waybill_url is None assert persisted_order.provider_waybill_url is None
assert persisted_order.created_at is not None assert persisted_order.created_at is not None
assert persisted_order.updated_at is not None assert persisted_order.updated_at is not None
@@ -175,7 +175,7 @@ def test_mark_payment_status_returns_none_for_missing_order() -> None:
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_persists_cdek_order_uuid_only() -> None: def test_mark_provider_order_registered_persists_provider_order_id_only() -> None:
async def run( async def run(
repository: OrderRepository, repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession], session_factory: async_sessionmaker[AsyncSession],
@@ -184,7 +184,7 @@ def test_mark_cdek_order_registered_persists_cdek_order_uuid_only() -> None:
await repository.create_order(session, _make_order_data()) await repository.create_order(session, _make_order_data())
async with repository.session() as session: async with repository.session() as session:
order = await repository.mark_cdek_order_registered( order = await repository.mark_provider_order_registered(
session, session,
"order-uuid-1", "order-uuid-1",
"cdek-order-uuid-1", "cdek-order-uuid-1",
@@ -197,14 +197,14 @@ def test_mark_cdek_order_registered_persists_cdek_order_uuid_only() -> None:
persisted_order = result.scalar_one() persisted_order = result.scalar_one()
assert order is not None assert order is not None
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1" assert persisted_order.provider_order_id == "cdek-order-uuid-1"
assert persisted_order.cdek_waybill_uuid is None assert persisted_order.provider_waybill_id is None
assert persisted_order.cdek_waybill_url is None assert persisted_order.provider_waybill_url is None
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_is_idempotent_for_same_uuid() -> None: def test_mark_provider_order_registered_is_idempotent_for_same_uuid() -> None:
async def run( async def run(
repository: OrderRepository, repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession], session_factory: async_sessionmaker[AsyncSession],
@@ -213,14 +213,14 @@ def test_mark_cdek_order_registered_is_idempotent_for_same_uuid() -> None:
await repository.create_order(session, _make_order_data()) await repository.create_order(session, _make_order_data())
async with repository.session() as session: async with repository.session() as session:
await repository.mark_cdek_order_registered( await repository.mark_provider_order_registered(
session, session,
"order-uuid-1", "order-uuid-1",
"cdek-order-uuid-1", "cdek-order-uuid-1",
) )
async with repository.session() as session: async with repository.session() as session:
await repository.mark_cdek_order_registered( await repository.mark_provider_order_registered(
session, session,
"order-uuid-1", "order-uuid-1",
"cdek-order-uuid-1", "cdek-order-uuid-1",
@@ -231,18 +231,18 @@ def test_mark_cdek_order_registered_is_idempotent_for_same_uuid() -> None:
orders = result.scalars().all() orders = result.scalars().all()
assert len(orders) == 1 assert len(orders) == 1
assert orders[0].cdek_order_uuid == "cdek-order-uuid-1" assert orders[0].provider_order_id == "cdek-order-uuid-1"
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_returns_none_for_missing_order() -> None: def test_mark_provider_order_registered_returns_none_for_missing_order() -> None:
async def run( async def run(
repository: OrderRepository, repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession], _session_factory: async_sessionmaker[AsyncSession],
) -> None: ) -> None:
async with repository.session() as session: async with repository.session() as session:
order = await repository.mark_cdek_order_registered( order = await repository.mark_provider_order_registered(
session, session,
"missing-order", "missing-order",
"cdek-order-uuid-1", "cdek-order-uuid-1",
@@ -257,37 +257,38 @@ async def _seed_order(
repository: OrderRepository, repository: OrderRepository,
*, *,
order_uuid: str, order_uuid: str,
cdek_order_uuid: str | None, provider_order_id: str | None,
cdek_order_status: str | None = None, provider: str = "cdek",
cdek_waybill_uuid: str | None = None, provider_order_status: str | None = None,
cdek_waybill_url: str | None = None, provider_waybill_id: str | None = None,
cdek_polled_at: datetime | None = None, provider_waybill_url: str | None = None,
provider_polled_at: datetime | None = None,
) -> None: ) -> None:
async with repository.session() as session: async with repository.session() as session:
await repository.create_order( await repository.create_order(
session, _make_order_data(order_uuid=order_uuid) session, _make_order_data(order_uuid=order_uuid, provider=provider)
) )
if cdek_order_uuid is not None: if provider_order_id is not None:
await repository.mark_cdek_order_registered( await repository.mark_provider_order_registered(
session, order_uuid, cdek_order_uuid session, order_uuid, provider_order_id
) )
if ( if (
cdek_order_status is not None provider_order_status is not None
or cdek_waybill_uuid is not None or provider_waybill_id is not None
or cdek_polled_at is not None or provider_polled_at is not None
): ):
order = await repository.get_order_by_order_uuid(session, order_uuid) order = await repository.get_order_by_order_uuid(session, order_uuid)
assert order is not None assert order is not None
if cdek_order_status is not None: if provider_order_status is not None:
order.cdek_order_status = cdek_order_status order.provider_order_status = provider_order_status
if cdek_waybill_uuid is not None: if provider_waybill_id is not None:
order.cdek_waybill_uuid = cdek_waybill_uuid order.provider_waybill_id = provider_waybill_id
if cdek_polled_at is not None: if provider_polled_at is not None:
order.cdek_polled_at = cdek_polled_at order.provider_polled_at = provider_polled_at
if cdek_waybill_url is not None: if provider_waybill_url is not None:
order = await repository.get_order_by_order_uuid(session, order_uuid) order = await repository.get_order_by_order_uuid(session, order_uuid)
assert order is not None assert order is not None
order.cdek_waybill_url = cdek_waybill_url order.provider_waybill_url = provider_waybill_url
def test_list_orders_pending_waybill_returns_orders_without_url() -> None: def test_list_orders_pending_waybill_returns_orders_without_url() -> None:
@@ -295,21 +296,26 @@ def test_list_orders_pending_waybill_returns_orders_without_url() -> None:
repository: OrderRepository, repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession], _session_factory: async_sessionmaker[AsyncSession],
) -> None: ) -> None:
await _seed_order(repository, order_uuid="pending", cdek_order_uuid="o1") await _seed_order(repository, order_uuid="pending", provider_order_id="o1")
await _seed_order( await _seed_order(
repository, repository,
order_uuid="done", order_uuid="done",
cdek_order_uuid="o2", provider_order_id="o2",
cdek_waybill_uuid="w2", provider_waybill_id="w2",
cdek_waybill_url="https://cdek.test/2.pdf", provider_waybill_url="https://cdek.test/2.pdf",
) )
await _seed_order( await _seed_order(
repository, repository,
order_uuid="invalid", order_uuid="invalid",
cdek_order_uuid="o3", provider_order_id="o3",
cdek_order_status="INVALID", provider_order_status="INVALID",
)
await _seed_order(
repository,
order_uuid="not-cdek",
provider="cse",
provider_order_id="cse-1",
) )
await _seed_order(repository, order_uuid="no-cdek", cdek_order_uuid=None)
async with repository.session() as session: async with repository.session() as session:
orders = await repository.list_orders_pending_waybill(session, limit=10) orders = await repository.list_orders_pending_waybill(session, limit=10)
@@ -330,16 +336,16 @@ def test_list_orders_pending_waybill_orders_polled_at_nulls_first() -> None:
await _seed_order( await _seed_order(
repository, repository,
order_uuid="late", order_uuid="late",
cdek_order_uuid="o-late", provider_order_id="o-late",
cdek_polled_at=later, provider_polled_at=later,
) )
await _seed_order( await _seed_order(
repository, repository,
order_uuid="early", order_uuid="early",
cdek_order_uuid="o-early", provider_order_id="o-early",
cdek_polled_at=earlier, provider_polled_at=earlier,
) )
await _seed_order(repository, order_uuid="never", cdek_order_uuid="o-never") await _seed_order(repository, order_uuid="never", provider_order_id="o-never")
async with repository.session() as session: async with repository.session() as session:
orders = await repository.list_orders_pending_waybill(session, limit=10) orders = await repository.list_orders_pending_waybill(session, limit=10)
@@ -356,7 +362,7 @@ def test_record_order_poll_sets_status_and_waybill_uuid() -> None:
repository: OrderRepository, repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession], _session_factory: async_sessionmaker[AsyncSession],
) -> None: ) -> None:
await _seed_order(repository, order_uuid="o", cdek_order_uuid="cdek-o") await _seed_order(repository, order_uuid="o", provider_order_id="cdek-o")
async with repository.session() as session: async with repository.session() as session:
order = await repository.record_order_poll( order = await repository.record_order_poll(
@@ -368,9 +374,9 @@ def test_record_order_poll_sets_status_and_waybill_uuid() -> None:
) )
assert order is not None assert order is not None
assert order.cdek_order_status == "ACCEPTED" assert order.provider_order_status == "ACCEPTED"
assert order.cdek_waybill_uuid == "waybill-1" assert order.provider_waybill_id == "waybill-1"
assert order.cdek_polled_at == polled assert order.provider_polled_at == polled
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
@@ -385,8 +391,8 @@ def test_record_order_poll_does_not_overwrite_existing_waybill_uuid() -> None:
await _seed_order( await _seed_order(
repository, repository,
order_uuid="o", order_uuid="o",
cdek_order_uuid="cdek-o", provider_order_id="cdek-o",
cdek_waybill_uuid="existing-waybill", provider_waybill_id="existing-waybill",
) )
async with repository.session() as session: async with repository.session() as session:
@@ -399,7 +405,7 @@ def test_record_order_poll_does_not_overwrite_existing_waybill_uuid() -> None:
) )
assert order is not None assert order is not None
assert order.cdek_waybill_uuid == "existing-waybill" assert order.provider_waybill_id == "existing-waybill"
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
@@ -414,8 +420,8 @@ def test_record_waybill_poll_sets_url_only_when_previously_null() -> None:
await _seed_order( await _seed_order(
repository, repository,
order_uuid="o", order_uuid="o",
cdek_order_uuid="cdek-o", provider_order_id="cdek-o",
cdek_waybill_uuid="waybill-1", provider_waybill_id="waybill-1",
) )
async with repository.session() as session: async with repository.session() as session:
@@ -427,8 +433,8 @@ def test_record_waybill_poll_sets_url_only_when_previously_null() -> None:
) )
assert order is not None assert order is not None
assert order.cdek_waybill_url == "https://cdek.test/1.pdf" assert order.provider_waybill_url == "https://cdek.test/1.pdf"
assert order.cdek_polled_at == polled assert order.provider_polled_at == polled
async with repository.session() as session: async with repository.session() as session:
order = await repository.record_waybill_poll( order = await repository.record_waybill_poll(
@@ -438,7 +444,7 @@ def test_record_waybill_poll_sets_url_only_when_previously_null() -> None:
polled_at=polled, polled_at=polled,
) )
assert order is not None assert order is not None
assert order.cdek_waybill_url == "https://cdek.test/1.pdf" assert order.provider_waybill_url == "https://cdek.test/1.pdf"
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
@@ -451,22 +457,22 @@ def test_list_orders_pending_waybill_email_returns_orders_with_url_and_no_sent_a
await _seed_order( await _seed_order(
repository, repository,
order_uuid="ready", order_uuid="ready",
cdek_order_uuid="o1", provider_order_id="o1",
cdek_waybill_uuid="w1", provider_waybill_id="w1",
cdek_waybill_url="https://cdek.test/1.pdf", provider_waybill_url="https://cdek.test/1.pdf",
) )
await _seed_order( await _seed_order(
repository, repository,
order_uuid="no-url", order_uuid="no-url",
cdek_order_uuid="o2", provider_order_id="o2",
cdek_waybill_uuid="w2", provider_waybill_id="w2",
) )
await _seed_order( await _seed_order(
repository, repository,
order_uuid="already-sent", order_uuid="already-sent",
cdek_order_uuid="o3", provider_order_id="o3",
cdek_waybill_uuid="w3", provider_waybill_id="w3",
cdek_waybill_url="https://cdek.test/3.pdf", provider_waybill_url="https://cdek.test/3.pdf",
) )
async with repository.session() as session: async with repository.session() as session:
sent = await repository.get_order_by_order_uuid(session, "already-sent") sent = await repository.get_order_by_order_uuid(session, "already-sent")
@@ -493,16 +499,16 @@ def test_list_orders_pending_waybill_email_orders_by_created_at_asc() -> None:
await _seed_order( await _seed_order(
repository, repository,
order_uuid="first", order_uuid="first",
cdek_order_uuid="o1", provider_order_id="o1",
cdek_waybill_uuid="w1", provider_waybill_id="w1",
cdek_waybill_url="https://cdek.test/1.pdf", provider_waybill_url="https://cdek.test/1.pdf",
) )
await _seed_order( await _seed_order(
repository, repository,
order_uuid="second", order_uuid="second",
cdek_order_uuid="o2", provider_order_id="o2",
cdek_waybill_uuid="w2", provider_waybill_id="w2",
cdek_waybill_url="https://cdek.test/2.pdf", provider_waybill_url="https://cdek.test/2.pdf",
) )
async with repository.session() as session: async with repository.session() as session:
@@ -526,9 +532,9 @@ def test_record_waybill_email_sent_sets_timestamp_once() -> None:
await _seed_order( await _seed_order(
repository, repository,
order_uuid="o", order_uuid="o",
cdek_order_uuid="cdek-o", provider_order_id="cdek-o",
cdek_waybill_uuid="w", provider_waybill_id="w",
cdek_waybill_url="https://cdek.test/1.pdf", provider_waybill_url="https://cdek.test/1.pdf",
) )
async with repository.session() as session: async with repository.session() as session:
@@ -580,8 +586,8 @@ def test_record_waybill_poll_updates_polled_at_when_url_is_none() -> None:
await _seed_order( await _seed_order(
repository, repository,
order_uuid="o", order_uuid="o",
cdek_order_uuid="cdek-o", provider_order_id="cdek-o",
cdek_waybill_uuid="waybill-1", provider_waybill_id="waybill-1",
) )
async with repository.session() as session: async with repository.session() as session:
@@ -593,7 +599,7 @@ def test_record_waybill_poll_updates_polled_at_when_url_is_none() -> None:
) )
assert order is not None assert order is not None
assert order.cdek_waybill_url is None assert order.provider_waybill_url is None
assert order.cdek_polled_at == polled assert order.provider_polled_at == polled
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
+5 -11
View File
@@ -115,8 +115,7 @@ class StoredOrder:
) )
payment_status: str | None = None payment_status: str | None = None
tbank_payment_id: int | None = None tbank_payment_id: int | None = None
cdek_order_uuid: str | None = None provider_order_id: str | None = None
cse_order_number: str | None = None
payment_email_sent_at: object | None = None payment_email_sent_at: object | None = None
account_email: str | None = None account_email: str | None = None
@@ -148,17 +147,12 @@ class StubOrderRepository:
self._order.tbank_payment_id = payment_id self._order.tbank_payment_id = payment_id
return self._order return self._order
async def mark_cse_order_registered( async def mark_provider_order_registered(
self, session: object, order_uuid: str, cse_order_number: str self, session: object, order_uuid: str, provider_order_id: str
) -> StoredOrder: ) -> StoredOrder:
self._order.cse_order_number = cse_order_number self._order.provider_order_id = provider_order_id
return self._order 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: def _notification() -> TBankPaymentNotification:
return TBankPaymentNotification( return TBankPaymentNotification(
@@ -190,4 +184,4 @@ def test_notification_routes_registration_to_order_provider() -> None:
assert result == "OK" assert result == "OK"
assert len(cse_registration.calls) == 1 assert len(cse_registration.calls) == 1
assert order.cse_order_number == "CSE-000123" assert order.provider_order_id == "CSE-000123"
+21 -22
View File
@@ -32,10 +32,9 @@ class StoredOrder:
payload: dict[str, Any] = field(default_factory=_default_payload) payload: dict[str, Any] = field(default_factory=_default_payload)
payment_status: str | None = None payment_status: str | None = None
tbank_payment_id: int | None = None tbank_payment_id: int | None = None
cdek_order_uuid: str | None = None provider_order_id: str | None = None
cse_order_number: str | None = None provider_waybill_id: str | None = None
cdek_waybill_uuid: str | None = None provider_waybill_url: str | None = None
cdek_waybill_url: str | None = None
payment_email_sent_at: object | None = None payment_email_sent_at: object | None = None
@@ -72,10 +71,10 @@ class StubOrderRepository:
self, self,
*, *,
orders: list[StoredOrder] | None = None, orders: list[StoredOrder] | None = None,
mark_cdek_errors: list[Exception | None] | None = None, mark_provider_errors: list[Exception | None] | None = None,
) -> None: ) -> None:
self._orders = {order.order_uuid: order for order in orders or []} self._orders = {order.order_uuid: order for order in orders or []}
self._mark_cdek_errors = mark_cdek_errors or [] self._mark_provider_errors = mark_provider_errors or []
self.session_value = object() self.session_value = object()
self.calls: list[tuple[str, tuple[object, ...]]] = [] self.calls: list[tuple[str, tuple[object, ...]]] = []
@@ -113,24 +112,24 @@ class StubOrderRepository:
order.tbank_payment_id = payment_id order.tbank_payment_id = payment_id
return order return order
async def mark_cdek_order_registered( async def mark_provider_order_registered(
self, self,
session: object, session: object,
order_uuid: str, order_uuid: str,
cdek_order_uuid: str, provider_order_id: str,
) -> StoredOrder | None: ) -> StoredOrder | None:
self.calls.append( self.calls.append(
("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid)) ("mark_provider_order_registered", (session, order_uuid, provider_order_id))
) )
if self._mark_cdek_errors: if self._mark_provider_errors:
error = self._mark_cdek_errors.pop(0) error = self._mark_provider_errors.pop(0)
if error is not None: if error is not None:
raise error raise error
order = self._orders.get(order_uuid) order = self._orders.get(order_uuid)
if order is None: if order is None:
return None return None
order.cdek_order_uuid = cdek_order_uuid order.provider_order_id = provider_order_id
return order return order
async def record_payment_email_sent( async def record_payment_email_sent(
@@ -230,15 +229,15 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
assert payment_adapter.notifications == [notification] assert payment_adapter.notifications == [notification]
assert order.payment_status == "CONFIRMED" assert order.payment_status == "CONFIRMED"
assert order.tbank_payment_id == 8347568144 assert order.tbank_payment_id == 8347568144
assert order.cdek_order_uuid == "cdek-order-uuid-1" assert order.provider_order_id == "cdek-order-uuid-1"
assert order.cdek_waybill_uuid is None assert order.provider_waybill_id is None
assert order.cdek_waybill_url is None assert order.provider_waybill_url is None
assert len(cdek_adapter.calls) == 1 assert len(cdek_adapter.calls) == 1
assert cdek_adapter.calls[0][1] == "order-uuid-1" assert cdek_adapter.calls[0][1] == "order-uuid-1"
def test_duplicate_confirmed_notification_does_not_call_cdek() -> None: def test_duplicate_confirmed_notification_does_not_call_cdek() -> None:
order = StoredOrder(cdek_order_uuid="existing-cdek-order-uuid") order = StoredOrder(provider_order_id="existing-cdek-order-uuid")
cdek_adapter = StubCDEKOrderAdapter() cdek_adapter = StubCDEKOrderAdapter()
service = AggregatorService( service = AggregatorService(
providers=[], providers=[],
@@ -253,7 +252,7 @@ def test_duplicate_confirmed_notification_does_not_call_cdek() -> None:
assert result == "OK" assert result == "OK"
assert cdek_adapter.calls == [] assert cdek_adapter.calls == []
assert order.cdek_order_uuid == "existing-cdek-order-uuid" assert order.provider_order_id == "existing-cdek-order-uuid"
def test_non_confirmed_notification_acknowledges_without_cdek() -> None: def test_non_confirmed_notification_acknowledges_without_cdek() -> None:
@@ -330,7 +329,7 @@ def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id()
order = StoredOrder() order = StoredOrder()
order_repository = StubOrderRepository( order_repository = StubOrderRepository(
orders=[order], orders=[order],
mark_cdek_errors=[RuntimeError("db down"), None], mark_provider_errors=[RuntimeError("db down"), None],
) )
cdek_adapter = StubCDEKOrderAdapter( cdek_adapter = StubCDEKOrderAdapter(
responses=[ responses=[
@@ -357,12 +356,12 @@ def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id()
with pytest.raises(TBankPaymentNotificationProcessingError): with pytest.raises(TBankPaymentNotificationProcessingError):
asyncio.run(service.handle_tbank_payment_notification(notification)) asyncio.run(service.handle_tbank_payment_notification(notification))
assert order.cdek_order_uuid is None assert order.provider_order_id is None
result = asyncio.run(service.handle_tbank_payment_notification(notification)) result = asyncio.run(service.handle_tbank_payment_notification(notification))
assert result == "OK" assert result == "OK"
assert order.cdek_order_uuid == "same-cdek-order-uuid" assert order.provider_order_id == "same-cdek-order-uuid"
assert [order_uuid for _, order_uuid in cdek_adapter.calls] == [ assert [order_uuid for _, order_uuid in cdek_adapter.calls] == [
"order-uuid-1", "order-uuid-1",
"order-uuid-1", "order-uuid-1",
@@ -417,7 +416,7 @@ def test_duplicate_notification_does_not_resend_payment_email() -> None:
from datetime import datetime, timezone from datetime import datetime, timezone
order = StoredOrder( order = StoredOrder(
cdek_order_uuid="existing-cdek-order-uuid", provider_order_id="existing-cdek-order-uuid",
payment_email_sent_at=datetime(2026, 1, 1, tzinfo=timezone.utc), payment_email_sent_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
) )
email_sender = StubEmailSender() email_sender = StubEmailSender()
@@ -454,4 +453,4 @@ def test_email_failure_does_not_break_notification_handling() -> None:
assert result == "OK" assert result == "OK"
assert len(email_sender.calls) == 1 assert len(email_sender.calls) == 1
assert order.cdek_order_uuid == "cdek-order-uuid-1" assert order.provider_order_id == "cdek-order-uuid-1"
+7 -7
View File
@@ -10,7 +10,7 @@ from app.services.waybill_email_sender import WaybillEmailSenderService
class StoredOrder: class StoredOrder:
order_uuid: str order_uuid: str
account_email: str account_email: str
cdek_waybill_url: str | None = None provider_waybill_url: str | None = None
waybill_email_sent_at: datetime | None = None waybill_email_sent_at: datetime | None = None
@@ -41,7 +41,7 @@ class StubRepository:
return [ return [
order order
for order in self._orders.values() for order in self._orders.values()
if order.cdek_waybill_url is not None if order.provider_waybill_url is not None
and order.waybill_email_sent_at is None and order.waybill_email_sent_at is None
] ]
@@ -125,7 +125,7 @@ def test_poll_once_downloads_pdf_sends_email_and_marks_sent() -> None:
order = StoredOrder( order = StoredOrder(
order_uuid="o-1", order_uuid="o-1",
account_email="client@example.com", account_email="client@example.com",
cdek_waybill_url="https://cdek.test/1.pdf", provider_waybill_url="https://cdek.test/1.pdf",
) )
repo = StubRepository([order]) repo = StubRepository([order])
downloader = StubDownloader({"https://cdek.test/1.pdf": b"%PDF"}) downloader = StubDownloader({"https://cdek.test/1.pdf": b"%PDF"})
@@ -155,7 +155,7 @@ def test_poll_once_download_error_keeps_order_pending_and_skips_send() -> None:
order = StoredOrder( order = StoredOrder(
order_uuid="o-1", order_uuid="o-1",
account_email="client@example.com", account_email="client@example.com",
cdek_waybill_url="https://cdek.test/1.pdf", provider_waybill_url="https://cdek.test/1.pdf",
) )
repo = StubRepository([order]) repo = StubRepository([order])
downloader = StubDownloader({"https://cdek.test/1.pdf": RuntimeError("cdek 500")}) downloader = StubDownloader({"https://cdek.test/1.pdf": RuntimeError("cdek 500")})
@@ -177,7 +177,7 @@ def test_poll_once_smtp_error_keeps_order_pending() -> None:
order = StoredOrder( order = StoredOrder(
order_uuid="o-1", order_uuid="o-1",
account_email="bad@example.com", account_email="bad@example.com",
cdek_waybill_url="https://cdek.test/1.pdf", provider_waybill_url="https://cdek.test/1.pdf",
) )
repo = StubRepository([order]) repo = StubRepository([order])
downloader = StubDownloader({"https://cdek.test/1.pdf": b"%PDF"}) downloader = StubDownloader({"https://cdek.test/1.pdf": b"%PDF"})
@@ -197,12 +197,12 @@ def test_poll_once_failure_on_one_order_does_not_break_batch() -> None:
bad = StoredOrder( bad = StoredOrder(
order_uuid="bad", order_uuid="bad",
account_email="bad@example.com", account_email="bad@example.com",
cdek_waybill_url="https://cdek.test/bad.pdf", provider_waybill_url="https://cdek.test/bad.pdf",
) )
good = StoredOrder( good = StoredOrder(
order_uuid="good", order_uuid="good",
account_email="good@example.com", account_email="good@example.com",
cdek_waybill_url="https://cdek.test/good.pdf", provider_waybill_url="https://cdek.test/good.pdf",
) )
repo = StubRepository([bad, good]) repo = StubRepository([bad, good])
downloader = StubDownloader( downloader = StubDownloader(
+33 -33
View File
@@ -13,11 +13,11 @@ from app.services.waybill_poller import WaybillPollerService
@dataclass @dataclass
class StoredOrder: class StoredOrder:
order_uuid: str order_uuid: str
cdek_order_uuid: str | None = None provider_order_id: str | None = None
cdek_order_status: str | None = None provider_order_status: str | None = None
cdek_waybill_uuid: str | None = None provider_waybill_id: str | None = None
cdek_waybill_url: str | None = None provider_waybill_url: str | None = None
cdek_polled_at: datetime | None = None provider_polled_at: datetime | None = None
class StubSessionContext: class StubSessionContext:
@@ -69,10 +69,10 @@ class StubRepository:
order = self._orders.get(order_uuid) order = self._orders.get(order_uuid)
if order is None: if order is None:
return None return None
order.cdek_order_status = order_status order.provider_order_status = order_status
if waybill_uuid is not None and order.cdek_waybill_uuid is None: if waybill_uuid is not None and order.provider_waybill_id is None:
order.cdek_waybill_uuid = waybill_uuid order.provider_waybill_id = waybill_uuid
order.cdek_polled_at = polled_at order.provider_polled_at = polled_at
return order return order
async def record_waybill_poll( async def record_waybill_poll(
@@ -96,9 +96,9 @@ class StubRepository:
order = self._orders.get(order_uuid) order = self._orders.get(order_uuid)
if order is None: if order is None:
return None return None
if waybill_url is not None and order.cdek_waybill_url is None: if waybill_url is not None and order.provider_waybill_url is None:
order.cdek_waybill_url = waybill_url order.provider_waybill_url = waybill_url
order.cdek_polled_at = polled_at order.provider_polled_at = polled_at
return order return order
@@ -107,9 +107,9 @@ class StubOrderInfoAdapter:
self._results = results self._results = results
self.calls: list[str] = [] self.calls: list[str] = []
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo: async def get_order(self, provider_order_id: str) -> CDEKOrderInfo:
self.calls.append(cdek_order_uuid) self.calls.append(provider_order_id)
result = self._results[cdek_order_uuid] result = self._results[provider_order_id]
if isinstance(result, Exception): if isinstance(result, Exception):
raise result raise result
return result return result
@@ -120,9 +120,9 @@ class StubWaybillInfoAdapter:
self._results = results self._results = results
self.calls: list[str] = [] self.calls: list[str] = []
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo: async def get_waybill(self, provider_waybill_id: str) -> CDEKWaybillInfo:
self.calls.append(cdek_waybill_uuid) self.calls.append(provider_waybill_id)
result = self._results[cdek_waybill_uuid] result = self._results[provider_waybill_id]
if isinstance(result, Exception): if isinstance(result, Exception):
raise result raise result
return result return result
@@ -147,7 +147,7 @@ def _make_service(
def test_poll_once_fetches_order_info_when_waybill_uuid_is_missing() -> None: def test_poll_once_fetches_order_info_when_waybill_uuid_is_missing() -> None:
order = StoredOrder(order_uuid="o", cdek_order_uuid="cdek-o") order = StoredOrder(order_uuid="o", provider_order_id="cdek-o")
repo = StubRepository([order]) repo = StubRepository([order])
order_info = StubOrderInfoAdapter( order_info = StubOrderInfoAdapter(
{ {
@@ -164,16 +164,16 @@ def test_poll_once_fetches_order_info_when_waybill_uuid_is_missing() -> None:
assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0 assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0
assert order_info.calls == ["cdek-o"] assert order_info.calls == ["cdek-o"]
assert order.cdek_order_status == "ACCEPTED" assert order.provider_order_status == "ACCEPTED"
assert order.cdek_waybill_uuid == "waybill-1" assert order.provider_waybill_id == "waybill-1"
assert order.cdek_polled_at == _POLLED_AT assert order.provider_polled_at == _POLLED_AT
def test_poll_once_fetches_waybill_info_when_waybill_uuid_is_present() -> None: def test_poll_once_fetches_waybill_info_when_waybill_uuid_is_present() -> None:
order = StoredOrder( order = StoredOrder(
order_uuid="o", order_uuid="o",
cdek_order_uuid="cdek-o", provider_order_id="cdek-o",
cdek_waybill_uuid="waybill-1", provider_waybill_id="waybill-1",
) )
repo = StubRepository([order]) repo = StubRepository([order])
waybill_info = StubWaybillInfoAdapter( waybill_info = StubWaybillInfoAdapter(
@@ -190,12 +190,12 @@ def test_poll_once_fetches_waybill_info_when_waybill_uuid_is_present() -> None:
assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0 assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0
assert waybill_info.calls == ["waybill-1"] assert waybill_info.calls == ["waybill-1"]
assert order.cdek_waybill_url == "https://cdek.test/1.pdf" assert order.provider_waybill_url == "https://cdek.test/1.pdf"
assert order.cdek_polled_at == _POLLED_AT assert order.provider_polled_at == _POLLED_AT
def test_poll_once_records_terminal_status_without_waybill() -> None: def test_poll_once_records_terminal_status_without_waybill() -> None:
order = StoredOrder(order_uuid="o", cdek_order_uuid="cdek-o") order = StoredOrder(order_uuid="o", provider_order_id="cdek-o")
repo = StubRepository([order]) repo = StubRepository([order])
order_info = StubOrderInfoAdapter( order_info = StubOrderInfoAdapter(
{ {
@@ -211,16 +211,16 @@ def test_poll_once_records_terminal_status_without_waybill() -> None:
summary = asyncio.run(service.poll_once()) summary = asyncio.run(service.poll_once())
assert summary.succeeded == 1 assert summary.succeeded == 1
assert order.cdek_order_status == "INVALID" assert order.provider_order_status == "INVALID"
assert order.cdek_waybill_uuid is None assert order.provider_waybill_id is None
def test_poll_once_failure_on_one_order_does_not_break_batch() -> None: def test_poll_once_failure_on_one_order_does_not_break_batch() -> None:
failing = StoredOrder(order_uuid="bad", cdek_order_uuid="cdek-bad") failing = StoredOrder(order_uuid="bad", provider_order_id="cdek-bad")
good = StoredOrder( good = StoredOrder(
order_uuid="good", order_uuid="good",
cdek_order_uuid="cdek-good", provider_order_id="cdek-good",
cdek_waybill_uuid="waybill-good", provider_waybill_id="waybill-good",
) )
repo = StubRepository([failing, good]) repo = StubRepository([failing, good])
order_info = StubOrderInfoAdapter({"cdek-bad": RuntimeError("cdek down")}) order_info = StubOrderInfoAdapter({"cdek-bad": RuntimeError("cdek down")})
@@ -241,7 +241,7 @@ def test_poll_once_failure_on_one_order_does_not_break_batch() -> None:
assert summary.processed == 2 assert summary.processed == 2
assert summary.succeeded == 1 assert summary.succeeded == 1
assert summary.failed == 1 assert summary.failed == 1
assert good.cdek_waybill_url == "https://cdek.test/good.pdf" assert good.provider_waybill_url == "https://cdek.test/good.pdf"
def test_run_forever_exits_when_stop_event_is_set() -> None: def test_run_forever_exits_when_stop_event_is_set() -> None:
@@ -3,7 +3,8 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from app.config import ( from app.config import (
AdapterConfig, CDEKDeliveryProviderConfig,
DeliveryProvidersConfig,
EmailAdapterConfig, EmailAdapterConfig,
ObservabilityConfig, ObservabilityConfig,
PostgresConfig, PostgresConfig,
@@ -17,10 +18,12 @@ from app.workers.waybill_email_sender import _run
def _make_settings() -> Settings: def _make_settings() -> Settings:
return Settings( return Settings(
adapter=AdapterConfig( delivery_providers=DeliveryProvidersConfig(
cdek_base_url="https://api.cdek.test/v2", cdek=CDEKDeliveryProviderConfig(
cdek_client_id="id", base_url="https://api.cdek.test/v2",
cdek_client_secret="secret", client_id="id",
client_secret="secret",
)
), ),
tbank_payment=TBankPaymentConfig( tbank_payment=TBankPaymentConfig(
init_url="https://pay.test/init", init_url="https://pay.test/init",
+8 -5
View File
@@ -10,7 +10,8 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from app.config import ( from app.config import (
AdapterConfig, CDEKDeliveryProviderConfig,
DeliveryProvidersConfig,
EmailAdapterConfig, EmailAdapterConfig,
ObservabilityConfig, ObservabilityConfig,
PostgresConfig, PostgresConfig,
@@ -24,10 +25,12 @@ from app.workers.waybill_poller import _run
def _make_settings() -> Settings: def _make_settings() -> Settings:
return Settings( return Settings(
adapter=AdapterConfig( delivery_providers=DeliveryProvidersConfig(
cdek_base_url="https://api.cdek.test/v2", cdek=CDEKDeliveryProviderConfig(
cdek_client_id="id", base_url="https://api.cdek.test/v2",
cdek_client_secret="secret", client_id="id",
client_secret="secret",
)
), ),
tbank_payment=TBankPaymentConfig( tbank_payment=TBankPaymentConfig(
init_url="https://pay.test/init", init_url="https://pay.test/init",