добавлено получение накладной в cse
Deploy / deploy (push) Successful in 2m33s

This commit is contained in:
Раис Юсупалиев
2026-06-27 07:10:31 +03:00
parent 2d8f31d3d9
commit facdde00c9
14 changed files with 581 additions and 100 deletions
+37 -1
View File
@@ -21,12 +21,17 @@ from app.adapters.delivery_providers.cse.mapper import (
map_cse_calc_response_for_tariff_code, map_cse_calc_response_for_tariff_code,
) )
from app.adapters.delivery_providers.cse.order_mapper import ( from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderInfo,
CSEOrderRegistrationParams, CSEOrderRegistrationParams,
CSEOrderRegistrationResult, CSEOrderRegistrationResult,
build_calc_body_for_calculation, build_calc_body_for_calculation,
build_calc_body_for_payment, build_calc_body_for_payment,
build_tracking_body_for_order,
build_waybill_print_form_body,
map_cse_tracking_response,
map_cse_save_order_request, map_cse_save_order_request,
map_cse_save_order_response, map_cse_save_order_response,
map_cse_waybill_print_form_response,
split_tariff_code, split_tariff_code,
) )
from app.adapters.delivery_providers.cse.soap import ( from app.adapters.delivery_providers.cse.soap import (
@@ -113,6 +118,30 @@ class CSEClient:
except CSEMappingError as exc: except CSEMappingError as exc:
raise CSEClientError(str(exc)) from exc raise CSEClientError(str(exc)) from exc
async def get_order(self, order_number: str) -> CSEOrderInfo:
root = await self._post(
"Tracking",
build_tracking_body_for_order(order_number),
request_error_message="CSE Tracking request was rejected with status",
)
try:
return map_cse_tracking_response(root)
except CSEMappingError as exc:
raise CSEClientError(str(exc)) from exc
async def download_waybill_pdf(self, waybill_number: str) -> bytes:
root = await self._post(
"GetFormsForDocuments",
build_waybill_print_form_body(waybill_number),
request_error_message=(
"CSE GetFormsForDocuments request was rejected with status"
),
)
try:
return map_cse_waybill_print_form_response(root)
except CSEMappingError as exc:
raise CSEClientError(str(exc)) from exc
async def _post( async def _post(
self, self,
operation: str, operation: str,
@@ -152,7 +181,8 @@ class CSEClient:
status_code=response.status_code, status_code=response.status_code,
) )
raise CSEClientError( raise CSEClientError(
f"CSE {operation} request failed with status {response.status_code}." f"CSE {operation} request failed with status "
f"{response.status_code}."
) )
if 400 <= response.status_code < 500: if 400 <= response.status_code < 500:
@@ -312,3 +342,9 @@ class CSEProvider(DeliveryProvider):
self, request: InitPaymentRequest, order_uuid: str self, request: InitPaymentRequest, order_uuid: str
) -> CSEOrderRegistrationResult: ) -> CSEOrderRegistrationResult:
return await self._client.save_order(request, order_uuid) return await self._client.save_order(request, order_uuid)
async def get_order(self, order_number: str) -> CSEOrderInfo:
return await self._client.get_order(order_number)
async def download_waybill_pdf(self, waybill_number: str) -> bytes:
return await self._client.download_waybill_pdf(waybill_number)
@@ -1,5 +1,7 @@
"""CSE Calc and SaveDocuments payload mappers.""" """CSE Calc, SaveDocuments and waybill payload mappers."""
import base64
import binascii
from dataclasses import dataclass from dataclasses import dataclass
from app.adapters.delivery_providers.cse.constants import ( from app.adapters.delivery_providers.cse.constants import (
@@ -32,6 +34,13 @@ class CSEOrderRegistrationResult:
order_number: str order_number: str
@dataclass(frozen=True)
class CSEOrderInfo:
order_number: str
status_code: str | None
waybill_number: str | None
def build_calc_body_for_calculation( def build_calc_body_for_calculation(
request: DeliveryCalculationRequest, request: DeliveryCalculationRequest,
delivery_type: str = "", delivery_type: str = "",
@@ -176,6 +185,73 @@ def map_cse_save_order_response(root: Element) -> CSEOrderRegistrationResult:
raise CSEMappingError("CSE SaveDocuments response is missing document Number.") raise CSEMappingError("CSE SaveDocuments response is missing document Number.")
def build_tracking_body_for_order(order_number: str) -> dict[str, Element]:
return {
"documents": Element(
key="Documents",
items=[Element(key=order_number)],
),
"parameters": Element(
key="parameters",
items=[
make_field("DocumentType", "Order"),
make_field("OnlySelectedType", True, "boolean"),
],
),
}
def map_cse_tracking_response(root: Element) -> CSEOrderInfo:
for document in root.items:
order_number = document.property_value("Number") or document.key
if not order_number:
continue
return CSEOrderInfo(
order_number=order_number,
status_code=_latest_tracking_status(document),
waybill_number=_extract_waybill_number(document),
)
raise CSEMappingError("CSE Tracking response is missing document data.")
def build_waybill_print_form_body(waybill_number: str) -> dict[str, Element]:
return {
"documents": Element(
key="Documents",
items=[Element(key=waybill_number)],
),
"parameters": Element(
key="parameters",
items=[
make_field("DocumentType", "waybill"),
make_field("Type", "print"),
make_field(
"Name",
"Универсальная печатная "
"форма документа НАКЛАДНАЯ",
),
make_field("Format", "pdf"),
make_field("OnlySelectedType", True, "boolean"),
],
),
}
def map_cse_waybill_print_form_response(root: Element) -> bytes:
for document in root.items:
bdata = document.bdata
if not bdata:
continue
try:
normalized_bdata = "".join(bdata.split())
return base64.b64decode(normalized_bdata, validate=True)
except (binascii.Error, ValueError) as exc:
raise CSEMappingError(
"CSE GetFormsForDocuments response contains invalid BData."
) from exc
raise CSEMappingError("CSE GetFormsForDocuments response is missing BData.")
def _parcel_type(request: DeliveryCalculationRequest) -> str | None: def _parcel_type(request: DeliveryCalculationRequest) -> str | None:
return request.parcel_type.value if request.parcel_type is not None else None return request.parcel_type.value if request.parcel_type is not None else None
@@ -190,3 +266,30 @@ def _compose_address(address: Address) -> str:
def _format_decimal(value: float) -> str: def _format_decimal(value: float) -> str:
return format(value, "g") return format(value, "g")
def _latest_tracking_status(document: Element) -> str | None:
dated: list[tuple[str, str]] = []
for state in document.items:
if not state.key:
continue
date_time = state.property_value("DateTime") or ""
dated.append((date_time, state.key))
if not dated:
return None
dated.sort(key=lambda item: item[0])
return dated[-1][1]
def _extract_waybill_number(document: Element) -> str | None:
for table in document.tables:
if table.key != "Waybills":
continue
for waybill in table.items:
document_type = waybill.property_value("DocumentType")
if document_type is not None and document_type.lower() != "waybill":
continue
number = waybill.property_value("Number") or waybill.key
if number:
return number
return None
@@ -18,6 +18,7 @@ SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"
# Child tags of an Element that hold nested Element lists. # Child tags of an Element that hold nested Element lists.
_LIST_TAGS = ("Fields", "List", "Tables", "Properties") _LIST_TAGS = ("Fields", "List", "Tables", "Properties")
_SCALAR_TAGS = ("Key", "Value", "ValueType") _SCALAR_TAGS = ("Key", "Value", "ValueType")
_BINARY_TAG = "BData"
@dataclass @dataclass
@@ -27,6 +28,7 @@ class Element:
key: str | None = None key: str | None = None
value: str | None = None value: str | None = None
value_type: str | None = None value_type: str | None = None
bdata: str | None = None
fields: list["Element"] = field(default_factory=list) fields: list["Element"] = field(default_factory=list)
items: list["Element"] = field(default_factory=list) # <List> items: list["Element"] = field(default_factory=list) # <List>
tables: list["Element"] = field(default_factory=list) tables: list["Element"] = field(default_factory=list)
@@ -159,6 +161,8 @@ def _parse_element(node: ET.Element) -> Element:
element.tables.append(_parse_element(child)) element.tables.append(_parse_element(child))
elif local == "Properties": elif local == "Properties":
element.properties.append(_parse_element(child)) element.properties.append(_parse_element(child))
elif local == _BINARY_TAG:
element.bdata = (child.text or "").strip()
return element return element
+19 -6
View File
@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES
@@ -95,9 +95,7 @@ class OrderRepository:
*, *,
limit: int, limit: int,
) -> Sequence[Order]: ) -> Sequence[Order]:
statement = ( cdek_pending = and_(
select(Order)
.where(
Order.provider == "cdek", Order.provider == "cdek",
Order.provider_order_id.is_not(None), Order.provider_order_id.is_not(None),
Order.provider_waybill_url.is_(None), Order.provider_waybill_url.is_(None),
@@ -106,6 +104,14 @@ class OrderRepository:
| Order.provider_order_status.not_in(TERMINAL_ORDER_STATUSES) | Order.provider_order_status.not_in(TERMINAL_ORDER_STATUSES)
), ),
) )
cse_pending = and_(
Order.provider == "cse",
Order.provider_order_id.is_not(None),
Order.provider_waybill_id.is_(None),
)
statement = (
select(Order)
.where(or_(cdek_pending, cse_pending))
.order_by(Order.provider_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)
@@ -173,11 +179,18 @@ class OrderRepository:
*, *,
limit: int, limit: int,
) -> Sequence[Order]: ) -> Sequence[Order]:
cdek_ready = and_(
Order.provider == "cdek",
Order.provider_waybill_url.is_not(None),
)
cse_ready = and_(
Order.provider == "cse",
Order.provider_waybill_id.is_not(None),
)
statement = ( statement = (
select(Order) select(Order)
.where( .where(
Order.provider == "cdek", or_(cdek_ready, cse_ready),
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())
+26 -12
View File
@@ -1,7 +1,7 @@
"""Background service that e-mails CDEK waybill PDFs to customers.""" """Background service that e-mails provider waybill PDFs to customers."""
import asyncio import asyncio
from collections.abc import Callable, Sequence from collections.abc import Callable, Mapping, Sequence
from contextlib import AbstractAsyncContextManager from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -15,15 +15,18 @@ logger = structlog.get_logger(__name__)
_EMAIL_SUBJECT_TEMPLATE = "Накладная по заказу {order_uuid}" _EMAIL_SUBJECT_TEMPLATE = "Накладная по заказу {order_uuid}"
_EMAIL_BODY_TEMPLATE = ( _EMAIL_BODY_TEMPLATE = (
"Здравствуйте!\n\n" "Здравствуйте!\n\n"
"По вашему заказу {order_uuid} сформирована транспортная накладная CDEK.\n" "По вашему заказу {order_uuid} сформирована "
"транспортная накладная.\n"
"PDF-файл накладной приложен к этому письму.\n" "PDF-файл накладной приложен к этому письму.\n"
)
_EMAIL_BODY_URL_LINE_TEMPLATE = (
"Также накладная доступна по ссылке: {waybill_url}\n" "Также накладная доступна по ссылке: {waybill_url}\n"
) )
_ATTACHMENT_FILENAME_TEMPLATE = "waybill_{order_uuid}.pdf" _ATTACHMENT_FILENAME_TEMPLATE = "waybill_{order_uuid}.pdf"
class WaybillPDFDownloaderProtocol(Protocol): class WaybillPDFDownloaderProtocol(Protocol):
async def download_waybill_pdf(self, url: str) -> bytes: ... async def download_waybill_pdf(self, order: "OrderRecord") -> bytes: ...
class EmailSenderProtocol(Protocol): class EmailSenderProtocol(Protocol):
@@ -40,7 +43,9 @@ class EmailSenderProtocol(Protocol):
class OrderRecord(Protocol): class OrderRecord(Protocol):
order_uuid: str order_uuid: str
provider: str
account_email: str account_email: str
provider_waybill_id: str | None
provider_waybill_url: str | None provider_waybill_url: str | None
@@ -72,13 +77,13 @@ class WaybillEmailSenderService:
self, self,
*, *,
order_repository: WaybillEmailSenderRepositoryProtocol, order_repository: WaybillEmailSenderRepositoryProtocol,
waybill_downloader: WaybillPDFDownloaderProtocol, waybill_downloaders: Mapping[str, WaybillPDFDownloaderProtocol],
email_sender: EmailSenderProtocol, email_sender: EmailSenderProtocol,
batch_size: int, batch_size: int,
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc), datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> None: ) -> None:
self._repository = order_repository self._repository = order_repository
self._waybill_downloader = waybill_downloader self._waybill_downloaders = dict(waybill_downloaders)
self._email_sender = email_sender self._email_sender = email_sender
self._batch_size = batch_size self._batch_size = batch_size
self._datetime_now = datetime_now self._datetime_now = datetime_now
@@ -99,6 +104,7 @@ class WaybillEmailSenderService:
logger.exception( logger.exception(
"waybill_email_order_failed", "waybill_email_order_failed",
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
provider=order.provider,
account_email=order.account_email, account_email=order.account_email,
) )
return SendBatchSummary( return SendBatchSummary(
@@ -133,15 +139,16 @@ 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.provider_waybill_url if order.provider_waybill_url is None and order.provider_waybill_id is None:
if waybill_url is None:
return return
pdf_bytes = await self._waybill_downloader.download_waybill_pdf(waybill_url) downloader = self._resolve_downloader(order.provider)
pdf_bytes = await downloader.download_waybill_pdf(order)
subject = _EMAIL_SUBJECT_TEMPLATE.format(order_uuid=order.order_uuid) subject = _EMAIL_SUBJECT_TEMPLATE.format(order_uuid=order.order_uuid)
body = _EMAIL_BODY_TEMPLATE.format( body = _EMAIL_BODY_TEMPLATE.format(order_uuid=order.order_uuid)
order_uuid=order.order_uuid, if order.provider_waybill_url is not None:
waybill_url=waybill_url, body += _EMAIL_BODY_URL_LINE_TEMPLATE.format(
waybill_url=order.provider_waybill_url,
) )
filename = _ATTACHMENT_FILENAME_TEMPLATE.format(order_uuid=order.order_uuid) filename = _ATTACHMENT_FILENAME_TEMPLATE.format(order_uuid=order.order_uuid)
@@ -162,6 +169,13 @@ class WaybillEmailSenderService:
logger.info( logger.info(
"waybill_email_sent", "waybill_email_sent",
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
provider=order.provider,
account_email=order.account_email, account_email=order.account_email,
sent_at=sent_at, sent_at=sent_at,
) )
def _resolve_downloader(self, provider: str) -> WaybillPDFDownloaderProtocol:
downloader = self._waybill_downloaders.get(provider)
if downloader is None:
raise RuntimeError(f"Waybill downloader is not configured for {provider}.")
return downloader
+61 -25
View File
@@ -1,7 +1,7 @@
"""Background service that polls CDEK for waybill updates.""" """Background service that polls providers for waybill updates."""
import asyncio import asyncio
from collections.abc import Callable, Sequence from collections.abc import Callable, Mapping, Sequence
from contextlib import AbstractAsyncContextManager from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -9,24 +9,20 @@ from typing import Protocol
import structlog import structlog
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo,
CDEKWaybillInfo,
)
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
class CDEKOrderInfoAdapterProtocol(Protocol): class OrderInfoAdapterProtocol(Protocol):
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo: ... async def get_order(self, provider_order_id: str) -> object: ...
class CDEKWaybillInfoAdapterProtocol(Protocol): class WaybillInfoAdapterProtocol(Protocol):
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo: ... async def get_waybill(self, provider_waybill_id: str) -> object: ...
class OrderRecord(Protocol): class OrderRecord(Protocol):
order_uuid: str order_uuid: str
provider: str
provider_order_id: str | None provider_order_id: str | None
provider_waybill_id: str | None provider_waybill_id: str | None
@@ -70,14 +66,14 @@ class WaybillPollerService:
self, self,
*, *,
order_repository: WaybillPollerRepositoryProtocol, order_repository: WaybillPollerRepositoryProtocol,
order_info_adapter: CDEKOrderInfoAdapterProtocol, order_info_adapters: Mapping[str, OrderInfoAdapterProtocol],
waybill_info_adapter: CDEKWaybillInfoAdapterProtocol, waybill_info_adapters: Mapping[str, WaybillInfoAdapterProtocol],
batch_size: int, batch_size: int,
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc), datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> None: ) -> None:
self._repository = order_repository self._repository = order_repository
self._order_info_adapter = order_info_adapter self._order_info_adapters = dict(order_info_adapters)
self._waybill_info_adapter = waybill_info_adapter self._waybill_info_adapters = dict(waybill_info_adapters)
self._batch_size = batch_size self._batch_size = batch_size
self._datetime_now = datetime_now self._datetime_now = datetime_now
@@ -97,6 +93,7 @@ class WaybillPollerService:
logger.exception( logger.exception(
"waybill_poll_order_failed", "waybill_poll_order_failed",
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
provider=order.provider,
provider_order_id=order.provider_order_id, provider_order_id=order.provider_order_id,
provider_waybill_id=order.provider_waybill_id, provider_waybill_id=order.provider_waybill_id,
) )
@@ -137,35 +134,74 @@ class WaybillPollerService:
provider_order_id = order.provider_order_id provider_order_id = order.provider_order_id
if provider_order_id is None: if provider_order_id is None:
return return
info = await self._order_info_adapter.get_order(provider_order_id) adapter = self._resolve_order_info_adapter(order.provider)
info = await adapter.get_order(provider_order_id)
status_code = _extract_status_code(info)
waybill_id = _extract_waybill_id(info)
await self._repository.record_order_poll( await self._repository.record_order_poll(
session, session,
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
order_status=info.status_code, order_status=status_code,
waybill_uuid=info.waybill_uuid, waybill_uuid=waybill_id,
polled_at=polled_at, polled_at=polled_at,
) )
logger.info( logger.info(
"waybill_poll_order_result", "waybill_poll_order_result",
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
provider=order.provider,
provider_order_id=provider_order_id, provider_order_id=provider_order_id,
provider_order_status=info.status_code, provider_order_status=status_code,
provider_waybill_id=info.waybill_uuid, provider_waybill_id=waybill_id,
) )
return return
waybill = await self._waybill_info_adapter.get_waybill( adapter = self._resolve_waybill_info_adapter(order.provider)
order.provider_waybill_id waybill = await adapter.get_waybill(order.provider_waybill_id)
) waybill_url = _extract_waybill_url(waybill)
await self._repository.record_waybill_poll( await self._repository.record_waybill_poll(
session, session,
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
waybill_url=waybill.url, waybill_url=waybill_url,
polled_at=polled_at, polled_at=polled_at,
) )
logger.info( logger.info(
"waybill_poll_waybill_result", "waybill_poll_waybill_result",
order_uuid=order.order_uuid, order_uuid=order.order_uuid,
provider=order.provider,
provider_waybill_id=order.provider_waybill_id, provider_waybill_id=order.provider_waybill_id,
provider_waybill_url=waybill.url, provider_waybill_url=waybill_url,
) )
def _resolve_order_info_adapter(self, provider: str) -> OrderInfoAdapterProtocol:
adapter = self._order_info_adapters.get(provider)
if adapter is None:
raise RuntimeError(f"Order info adapter is not configured for {provider}.")
return adapter
def _resolve_waybill_info_adapter(
self, provider: str
) -> WaybillInfoAdapterProtocol:
adapter = self._waybill_info_adapters.get(provider)
if adapter is None:
raise RuntimeError(
f"Waybill info adapter is not configured for {provider}."
)
return adapter
def _extract_status_code(info: object) -> str | None:
value = getattr(info, "status_code", None)
return value if isinstance(value, str) and value else None
def _extract_waybill_id(info: object) -> str | None:
for attr in ("waybill_id", "waybill_uuid", "waybill_number"):
value = getattr(info, attr, None)
if isinstance(value, str) and value:
return value
return None
def _extract_waybill_url(info: object) -> str | None:
value = getattr(info, "url", None)
return value if isinstance(value, str) and value else None
+58 -3
View File
@@ -1,13 +1,21 @@
"""Background worker that e-mails CDEK waybill PDFs.""" """Background worker that e-mails provider waybill PDFs."""
import asyncio import asyncio
import signal import signal
from dataclasses import dataclass
import httpx import httpx
import structlog import structlog
from app.adapters.delivery_providers.registry import (
resolve_delivery_provider_timeout_seconds,
)
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
from app.adapters.delivery_providers.cdek.client import CDEKClient from app.adapters.delivery_providers.cdek.client import CDEKClient
from app.adapters.delivery_providers.cse.client import CSEClient
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderRegistrationParams,
)
from app.adapters.email import SMTPEmailSender from app.adapters.email import SMTPEmailSender
from app.adapters.postgres.engine import ( from app.adapters.postgres.engine import (
create_postgres_engine, create_postgres_engine,
@@ -21,9 +29,36 @@ from app.services.waybill_email_sender import WaybillEmailSenderService
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@dataclass(frozen=True)
class CDEKWaybillPDFDownloader:
client: CDEKClient
async def download_waybill_pdf(self, order: object) -> bytes:
url = getattr(order, "provider_waybill_url", None)
if not isinstance(url, str) or not url:
raise RuntimeError("CDEK waybill URL is missing.")
return await self.client.download_waybill_pdf(url)
@dataclass(frozen=True)
class CSEWaybillPDFDownloader:
client: CSEClient
async def download_waybill_pdf(self, order: object) -> bytes:
waybill_number = getattr(order, "provider_waybill_id", None)
if not isinstance(waybill_number, str) or not waybill_number:
raise RuntimeError("CSE waybill number is missing.")
return await self.client.download_waybill_pdf(waybill_number)
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=resolve_delivery_provider_timeout_seconds(settings.delivery_providers)
)
waybill_downloaders: dict[str, object] = {}
cdek_config = settings.delivery_providers.cdek cdek_config = settings.delivery_providers.cdek
http_client = httpx.AsyncClient(timeout=cdek_config.timeout_seconds) if cdek_config.enabled:
auth_client = CDEKAuthClient( auth_client = CDEKAuthClient(
http_client=http_client, http_client=http_client,
base_url=cdek_config.base_url, base_url=cdek_config.base_url,
@@ -39,6 +74,26 @@ async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
retry_attempts=cdek_config.retry_attempts, retry_attempts=cdek_config.retry_attempts,
retry_backoff_seconds=cdek_config.retry_backoff_seconds, retry_backoff_seconds=cdek_config.retry_backoff_seconds,
) )
waybill_downloaders["cdek"] = CDEKWaybillPDFDownloader(cdek_client)
cse_config = settings.delivery_providers.cse
if cse_config.enabled:
cse_client = CSEClient(
http_client=http_client,
base_url=cse_config.base_url,
login=cse_config.login,
password=cse_config.password,
registration_params=CSEOrderRegistrationParams(
payer=cse_config.payer,
payment_method=cse_config.payment_method,
shipping_method=cse_config.shipping_method,
),
timeout_seconds=cse_config.timeout_seconds,
retry_attempts=cse_config.retry_attempts,
retry_backoff_seconds=cse_config.retry_backoff_seconds,
)
waybill_downloaders["cse"] = CSEWaybillPDFDownloader(cse_client)
email_sender = SMTPEmailSender( email_sender = SMTPEmailSender(
smtp_host=settings.email.smtp_host, smtp_host=settings.email.smtp_host,
smtp_port=settings.email.smtp_port, smtp_port=settings.email.smtp_port,
@@ -53,7 +108,7 @@ async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
repository = OrderRepository(session_factory=session_factory) repository = OrderRepository(session_factory=session_factory)
service = WaybillEmailSenderService( service = WaybillEmailSenderService(
order_repository=repository, order_repository=repository,
waybill_downloader=cdek_client, waybill_downloaders=waybill_downloaders,
email_sender=email_sender, email_sender=email_sender,
batch_size=settings.waybill_email_sender.batch_size, batch_size=settings.waybill_email_sender.batch_size,
) )
+38 -4
View File
@@ -1,4 +1,4 @@
"""Background worker that polls CDEK for waybill updates.""" """Background worker that polls delivery providers for waybill updates."""
import asyncio import asyncio
import signal import signal
@@ -6,8 +6,15 @@ import signal
import httpx import httpx
import structlog import structlog
from app.adapters.delivery_providers.registry import (
resolve_delivery_provider_timeout_seconds,
)
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
from app.adapters.delivery_providers.cdek.client import CDEKClient from app.adapters.delivery_providers.cdek.client import CDEKClient
from app.adapters.delivery_providers.cse.client import CSEClient
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderRegistrationParams,
)
from app.adapters.postgres.engine import ( from app.adapters.postgres.engine import (
create_postgres_engine, create_postgres_engine,
create_postgres_session_factory, create_postgres_session_factory,
@@ -21,8 +28,14 @@ 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=resolve_delivery_provider_timeout_seconds(settings.delivery_providers)
)
order_info_adapters: dict[str, object] = {}
waybill_info_adapters: dict[str, object] = {}
cdek_config = settings.delivery_providers.cdek cdek_config = settings.delivery_providers.cdek
http_client = httpx.AsyncClient(timeout=cdek_config.timeout_seconds) if cdek_config.enabled:
auth_client = CDEKAuthClient( auth_client = CDEKAuthClient(
http_client=http_client, http_client=http_client,
base_url=cdek_config.base_url, base_url=cdek_config.base_url,
@@ -38,13 +51,34 @@ async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
retry_attempts=cdek_config.retry_attempts, retry_attempts=cdek_config.retry_attempts,
retry_backoff_seconds=cdek_config.retry_backoff_seconds, retry_backoff_seconds=cdek_config.retry_backoff_seconds,
) )
order_info_adapters["cdek"] = cdek_client
waybill_info_adapters["cdek"] = cdek_client
cse_config = settings.delivery_providers.cse
if cse_config.enabled:
cse_client = CSEClient(
http_client=http_client,
base_url=cse_config.base_url,
login=cse_config.login,
password=cse_config.password,
registration_params=CSEOrderRegistrationParams(
payer=cse_config.payer,
payment_method=cse_config.payment_method,
shipping_method=cse_config.shipping_method,
),
timeout_seconds=cse_config.timeout_seconds,
retry_attempts=cse_config.retry_attempts,
retry_backoff_seconds=cse_config.retry_backoff_seconds,
)
order_info_adapters["cse"] = cse_client
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)
repository = OrderRepository(session_factory=session_factory) repository = OrderRepository(session_factory=session_factory)
service = WaybillPollerService( service = WaybillPollerService(
order_repository=repository, order_repository=repository,
order_info_adapter=cdek_client, order_info_adapters=order_info_adapters,
waybill_info_adapter=cdek_client, waybill_info_adapters=waybill_info_adapters,
batch_size=settings.waybill_poller.batch_size, batch_size=settings.waybill_poller.batch_size,
) )
logger.info( logger.info(
@@ -70,6 +70,62 @@ _SAVE_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
</soap:Body> </soap:Body>
</soap:Envelope>""" </soap:Envelope>"""
_TRACKING_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:TrackingResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>Tracking</m:Key>
<m:List>
<m:Key>CSE-000123</m:Key>
<m:Value>Order</m:Value>
<m:Properties><m:Key>Number</m:Key><m:Value>CSE-000123</m:Value></m:Properties>
<m:List>
<m:Key>Заказ принят, идет обработка заказа.</m:Key>
<m:Properties>
<m:Key>DateTime</m:Key><m:Value>2026-06-01T10:00:00</m:Value>
</m:Properties>
</m:List>
<m:List>
<m:Key>Накладная оформлена.</m:Key>
<m:Properties>
<m:Key>DateTime</m:Key><m:Value>2026-06-01T10:02:00</m:Value>
</m:Properties>
</m:List>
<m:Tables>
<m:Key>Waybills</m:Key>
<m:List>
<m:Key>496-AA-1676378</m:Key>
<m:Properties>
<m:Key>DocumentType</m:Key><m:Value>Waybill</m:Value>
</m:Properties>
<m:Properties>
<m:Key>Number</m:Key><m:Value>496-AA-1676378</m:Value>
</m:Properties>
</m:List>
</m:Tables>
</m:List>
</m:return>
</m:TrackingResponse>
</soap:Body>
</soap:Envelope>"""
_FORM_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:GetFormsForDocumentsResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>GetPrintForms</m:Key>
<m:List>
<m:Key>496-AA-1676378</m:Key>
<m:Properties><m:Key>FormFormat</m:Key><m:Value>PDF</m:Value></m:Properties>
<m:BData>JVBERg==</m:BData>
</m:List>
</m:return>
</m:GetFormsForDocumentsResponse>
</soap:Body>
</soap:Envelope>"""
_ERROR_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?> _ERROR_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"> <soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body> <soap:Body>
@@ -253,6 +309,40 @@ def test_provider_register_order_returns_document_number() -> None:
assert http_client.calls[0]["url"] == "http://lk-test.cse.ru/1c/ws/web1c.1cws" assert http_client.calls[0]["url"] == "http://lk-test.cse.ru/1c/ws/web1c.1cws"
def test_provider_get_order_extracts_waybill_number_from_tracking() -> None:
response = httpx.Response(200, text=_TRACKING_RESPONSE)
client, http_client = _build_client([response])
provider = CSEProvider(client)
result = asyncio.run(provider.get_order("CSE-000123"))
assert result.order_number == "CSE-000123"
assert result.status_code == "Накладная оформлена."
assert result.waybill_number == "496-AA-1676378"
content = http_client.calls[0]["content"].decode("utf-8")
assert "Tracking" in content
assert "DocumentType" in content
assert "Order" in content
def test_provider_download_waybill_pdf_uses_print_form() -> None:
response = httpx.Response(200, text=_FORM_RESPONSE)
client, http_client = _build_client([response])
provider = CSEProvider(client)
pdf = asyncio.run(provider.download_waybill_pdf("496-AA-1676378"))
assert pdf == b"%PDF"
content = http_client.calls[0]["content"].decode("utf-8")
assert "GetFormsForDocuments" in content
assert "DocumentType" in content
assert "waybill" in content
assert "Type" in content
assert "print" in content
assert "Format" in content
assert "pdf" in content
def test_save_order_request_uses_selected_tariff_urgency() -> None: def test_save_order_request_uses_selected_tariff_urgency() -> None:
request = make_init_payment_request( request = make_init_payment_request(
systemData={ systemData={
+24 -4
View File
@@ -312,15 +312,22 @@ def test_list_orders_pending_waybill_returns_orders_without_url() -> None:
) )
await _seed_order( await _seed_order(
repository, repository,
order_uuid="not-cdek", order_uuid="cse-pending",
provider="cse", provider="cse",
provider_order_id="cse-1", provider_order_id="cse-1",
) )
await _seed_order(
repository,
order_uuid="cse-with-waybill",
provider="cse",
provider_order_id="cse-2",
provider_waybill_id="496-AA-1676378",
)
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)
assert [order.order_uuid for order in orders] == ["pending"] assert [order.order_uuid for order in orders] == ["pending", "cse-pending"]
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
@@ -449,7 +456,7 @@ def test_record_waybill_poll_sets_url_only_when_previously_null() -> None:
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
def test_list_orders_pending_waybill_email_returns_orders_with_url_and_no_sent_at() -> None: def test_list_orders_pending_waybill_email_returns_ready_orders() -> None:
async def run( async def run(
repository: OrderRepository, repository: OrderRepository,
_session_factory: async_sessionmaker[AsyncSession], _session_factory: async_sessionmaker[AsyncSession],
@@ -474,6 +481,19 @@ def test_list_orders_pending_waybill_email_returns_orders_with_url_and_no_sent_a
provider_waybill_id="w3", provider_waybill_id="w3",
provider_waybill_url="https://cdek.test/3.pdf", provider_waybill_url="https://cdek.test/3.pdf",
) )
await _seed_order(
repository,
order_uuid="cse-ready",
provider="cse",
provider_order_id="cse-o1",
provider_waybill_id="496-AA-1676378",
)
await _seed_order(
repository,
order_uuid="cse-no-waybill",
provider="cse",
provider_order_id="cse-o2",
)
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")
assert sent is not None assert sent is not None
@@ -486,7 +506,7 @@ def test_list_orders_pending_waybill_email_returns_orders_with_url_and_no_sent_a
session, limit=10 session, limit=10
) )
assert [order.order_uuid for order in orders] == ["ready"] assert [order.order_uuid for order in orders] == ["ready", "cse-ready"]
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
+41 -5
View File
@@ -10,6 +10,8 @@ from app.services.waybill_email_sender import WaybillEmailSenderService
class StoredOrder: class StoredOrder:
order_uuid: str order_uuid: str
account_email: str account_email: str
provider: str = "cdek"
provider_waybill_id: str | None = None
provider_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 +43,10 @@ class StubRepository:
return [ return [
order order
for order in self._orders.values() for order in self._orders.values()
if order.provider_waybill_url is not None if (
order.provider_waybill_url is not None
or order.provider_waybill_id is not None
)
and order.waybill_email_sent_at is None and order.waybill_email_sent_at is None
] ]
@@ -68,9 +73,11 @@ class StubDownloader:
self._results = results self._results = results
self.calls: list[str] = [] self.calls: list[str] = []
async def download_waybill_pdf(self, url: str) -> bytes: async def download_waybill_pdf(self, order: StoredOrder) -> bytes:
self.calls.append(url) locator = order.provider_waybill_url or order.provider_waybill_id
result = self._results[url] assert locator is not None
self.calls.append(locator)
result = self._results[locator]
if isinstance(result, Exception): if isinstance(result, Exception):
raise result raise result
return result return result
@@ -114,7 +121,7 @@ def _make_service(
) -> WaybillEmailSenderService: ) -> WaybillEmailSenderService:
return WaybillEmailSenderService( return WaybillEmailSenderService(
order_repository=repository, order_repository=repository,
waybill_downloader=downloader or StubDownloader({}), waybill_downloaders={"cdek": downloader or StubDownloader({})},
email_sender=email_sender or StubEmailSender(), email_sender=email_sender or StubEmailSender(),
batch_size=10, batch_size=10,
datetime_now=lambda: _SENT_AT, datetime_now=lambda: _SENT_AT,
@@ -151,6 +158,35 @@ def test_poll_once_downloads_pdf_sends_email_and_marks_sent() -> None:
assert order.waybill_email_sent_at == _SENT_AT assert order.waybill_email_sent_at == _SENT_AT
def test_poll_once_sends_cse_waybill_pdf_without_url() -> None:
order = StoredOrder(
order_uuid="o-1",
provider="cse",
account_email="client@example.com",
provider_waybill_id="496-AA-1676378",
)
repo = StubRepository([order])
downloader = StubDownloader({"496-AA-1676378": b"%PDF"})
email_sender = StubEmailSender()
service = WaybillEmailSenderService(
order_repository=repo,
waybill_downloaders={"cse": downloader},
email_sender=email_sender,
batch_size=10,
datetime_now=lambda: _SENT_AT,
)
summary = asyncio.run(service.poll_once())
assert summary.processed == 1
assert summary.succeeded == 1
assert downloader.calls == ["496-AA-1676378"]
assert len(email_sender.calls) == 1
assert "https://" not in email_sender.calls[0]["body"]
assert email_sender.calls[0]["attachment_bytes"] == b"%PDF"
assert order.waybill_email_sent_at == _SENT_AT
def test_poll_once_download_error_keeps_order_pending_and_skips_send() -> None: 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",
+40 -2
View File
@@ -7,12 +7,14 @@ from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo, CDEKOrderInfo,
CDEKWaybillInfo, CDEKWaybillInfo,
) )
from app.adapters.delivery_providers.cse.order_mapper import CSEOrderInfo
from app.services.waybill_poller import WaybillPollerService from app.services.waybill_poller import WaybillPollerService
@dataclass @dataclass
class StoredOrder: class StoredOrder:
order_uuid: str order_uuid: str
provider: str = "cdek"
provider_order_id: str | None = None provider_order_id: str | None = None
provider_order_status: str | None = None provider_order_status: str | None = None
provider_waybill_id: str | None = None provider_waybill_id: str | None = None
@@ -129,6 +131,9 @@ class StubWaybillInfoAdapter:
_POLLED_AT = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc) _POLLED_AT = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
_CSE_WAYBILL_CREATED_STATUS = (
"На основании заказа оформлена накладная."
)
def _make_service( def _make_service(
@@ -139,8 +144,8 @@ def _make_service(
) -> WaybillPollerService: ) -> WaybillPollerService:
return WaybillPollerService( return WaybillPollerService(
order_repository=repository, order_repository=repository,
order_info_adapter=order_info or StubOrderInfoAdapter({}), order_info_adapters={"cdek": order_info or StubOrderInfoAdapter({})},
waybill_info_adapter=waybill_info or StubWaybillInfoAdapter({}), waybill_info_adapters={"cdek": waybill_info or StubWaybillInfoAdapter({})},
batch_size=10, batch_size=10,
datetime_now=lambda: _POLLED_AT, datetime_now=lambda: _POLLED_AT,
) )
@@ -194,6 +199,39 @@ def test_poll_once_fetches_waybill_info_when_waybill_uuid_is_present() -> None:
assert order.provider_polled_at == _POLLED_AT assert order.provider_polled_at == _POLLED_AT
def test_poll_once_fetches_cse_waybill_number_from_order_tracking() -> None:
order = StoredOrder(
order_uuid="o",
provider="cse",
provider_order_id="CSE-000123",
)
repo = StubRepository([order])
order_info = StubOrderInfoAdapter(
{
"CSE-000123": CSEOrderInfo(
order_number="CSE-000123",
status_code=_CSE_WAYBILL_CREATED_STATUS,
waybill_number="496-AA-1676378",
)
}
)
service = WaybillPollerService(
order_repository=repo,
order_info_adapters={"cse": order_info},
waybill_info_adapters={},
batch_size=10,
datetime_now=lambda: _POLLED_AT,
)
summary = asyncio.run(service.poll_once())
assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0
assert order_info.calls == ["CSE-000123"]
assert order.provider_order_status == _CSE_WAYBILL_CREATED_STATUS
assert order.provider_waybill_id == "496-AA-1676378"
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", provider_order_id="cdek-o") order = StoredOrder(order_uuid="o", provider_order_id="cdek-o")
repo = StubRepository([order]) repo = StubRepository([order])
@@ -64,6 +64,7 @@ def test_run_exits_when_stop_event_is_set() -> None:
), ),
patch("app.workers.waybill_email_sender.CDEKAuthClient"), patch("app.workers.waybill_email_sender.CDEKAuthClient"),
patch("app.workers.waybill_email_sender.CDEKClient"), patch("app.workers.waybill_email_sender.CDEKClient"),
patch("app.workers.waybill_email_sender.CSEClient"),
patch("app.workers.waybill_email_sender.SMTPEmailSender"), patch("app.workers.waybill_email_sender.SMTPEmailSender"),
patch( patch(
"app.workers.waybill_email_sender.create_postgres_engine", "app.workers.waybill_email_sender.create_postgres_engine",
@@ -69,6 +69,7 @@ def test_run_exits_when_stop_event_is_set() -> None:
), ),
patch("app.workers.waybill_poller.CDEKAuthClient"), patch("app.workers.waybill_poller.CDEKAuthClient"),
patch("app.workers.waybill_poller.CDEKClient"), patch("app.workers.waybill_poller.CDEKClient"),
patch("app.workers.waybill_poller.CSEClient"),
patch( patch(
"app.workers.waybill_poller.create_postgres_engine", "app.workers.waybill_poller.create_postgres_engine",
return_value=engine_instance, return_value=engine_instance,