Добавлен поллер накладной

This commit is contained in:
Раис Юсупалиев
2026-05-23 20:24:23 +03:00
parent c494d50566
commit 5526f90cb3
23 changed files with 1481 additions and 61 deletions
+3 -16
View File
@@ -113,20 +113,9 @@ class StubOrderRepository:
session: object,
order_uuid: str,
cdek_order_uuid: str,
cdek_waybill_uuid: str | None = None,
cdek_waybill_url: str | None = None,
) -> StoredOrder | None:
self.calls.append(
(
"mark_cdek_order_registered",
(
session,
order_uuid,
cdek_order_uuid,
cdek_waybill_uuid,
cdek_waybill_url,
),
)
("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid))
)
if self._mark_cdek_errors:
error = self._mark_cdek_errors.pop(0)
@@ -137,8 +126,6 @@ class StubOrderRepository:
if order is None:
return None
order.cdek_order_uuid = cdek_order_uuid
order.cdek_waybill_uuid = cdek_waybill_uuid
order.cdek_waybill_url = cdek_waybill_url
return order
@@ -211,8 +198,8 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
assert order.payment_status == "CONFIRMED"
assert order.tbank_payment_id == 8347568144
assert order.cdek_order_uuid == "cdek-order-uuid-1"
assert order.cdek_waybill_uuid == "waybill-uuid-1"
assert order.cdek_waybill_url == "https://cdek.test/waybill/1.pdf"
assert order.cdek_waybill_uuid is None
assert order.cdek_waybill_url is None
assert len(cdek_adapter.calls) == 1
assert cdek_adapter.calls[0].order_uuid == "order-uuid-1"
+259
View File
@@ -0,0 +1,259 @@
import asyncio
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo,
CDEKWaybillInfo,
)
from app.services.waybill_poller import WaybillPollerService
@dataclass
class StoredOrder:
order_uuid: str
cdek_order_uuid: str | None = None
cdek_order_status: str | None = None
cdek_waybill_uuid: str | None = None
cdek_waybill_url: str | None = None
cdek_polled_at: datetime | None = None
class StubSessionContext:
def __init__(self, session: object) -> None:
self._session = session
async def __aenter__(self) -> object:
return self._session
async def __aexit__(self, exc_type, exc, traceback) -> None:
return None
class StubRepository:
def __init__(self, orders: list[StoredOrder]) -> None:
self._orders = {order.order_uuid: order for order in orders}
self.session_value = object()
self.calls: list[tuple[str, dict[str, Any]]] = []
def session(self) -> StubSessionContext:
return StubSessionContext(self.session_value)
async def list_orders_pending_waybill(
self, session: object, *, limit: int
) -> list[StoredOrder]:
self.calls.append(("list", {"session": session, "limit": limit}))
return list(self._orders.values())
async def record_order_poll(
self,
session: object,
*,
order_uuid: str,
order_status: str | None,
waybill_uuid: str | None,
polled_at: datetime,
) -> StoredOrder | None:
self.calls.append(
(
"record_order_poll",
{
"order_uuid": order_uuid,
"order_status": order_status,
"waybill_uuid": waybill_uuid,
"polled_at": polled_at,
},
)
)
order = self._orders.get(order_uuid)
if order is None:
return None
order.cdek_order_status = order_status
if waybill_uuid is not None and order.cdek_waybill_uuid is None:
order.cdek_waybill_uuid = waybill_uuid
order.cdek_polled_at = polled_at
return order
async def record_waybill_poll(
self,
session: object,
*,
order_uuid: str,
waybill_url: str | None,
polled_at: datetime,
) -> StoredOrder | None:
self.calls.append(
(
"record_waybill_poll",
{
"order_uuid": order_uuid,
"waybill_url": waybill_url,
"polled_at": polled_at,
},
)
)
order = self._orders.get(order_uuid)
if order is None:
return None
if waybill_url is not None and order.cdek_waybill_url is None:
order.cdek_waybill_url = waybill_url
order.cdek_polled_at = polled_at
return order
class StubOrderInfoAdapter:
def __init__(self, results: dict[str, CDEKOrderInfo | Exception]) -> None:
self._results = results
self.calls: list[str] = []
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
self.calls.append(cdek_order_uuid)
result = self._results[cdek_order_uuid]
if isinstance(result, Exception):
raise result
return result
class StubWaybillInfoAdapter:
def __init__(self, results: dict[str, CDEKWaybillInfo | Exception]) -> None:
self._results = results
self.calls: list[str] = []
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo:
self.calls.append(cdek_waybill_uuid)
result = self._results[cdek_waybill_uuid]
if isinstance(result, Exception):
raise result
return result
_POLLED_AT = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
def _make_service(
*,
repository: StubRepository,
order_info: StubOrderInfoAdapter | None = None,
waybill_info: StubWaybillInfoAdapter | None = None,
) -> WaybillPollerService:
return WaybillPollerService(
order_repository=repository,
order_info_adapter=order_info or StubOrderInfoAdapter({}),
waybill_info_adapter=waybill_info or StubWaybillInfoAdapter({}),
batch_size=10,
datetime_now=lambda: _POLLED_AT,
)
def test_poll_once_fetches_order_info_when_waybill_uuid_is_missing() -> None:
order = StoredOrder(order_uuid="o", cdek_order_uuid="cdek-o")
repo = StubRepository([order])
order_info = StubOrderInfoAdapter(
{
"cdek-o": CDEKOrderInfo(
order_uuid="cdek-o",
status_code="ACCEPTED",
waybill_uuid="waybill-1",
)
}
)
service = _make_service(repository=repo, order_info=order_info)
summary = asyncio.run(service.poll_once())
assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0
assert order_info.calls == ["cdek-o"]
assert order.cdek_order_status == "ACCEPTED"
assert order.cdek_waybill_uuid == "waybill-1"
assert order.cdek_polled_at == _POLLED_AT
def test_poll_once_fetches_waybill_info_when_waybill_uuid_is_present() -> None:
order = StoredOrder(
order_uuid="o",
cdek_order_uuid="cdek-o",
cdek_waybill_uuid="waybill-1",
)
repo = StubRepository([order])
waybill_info = StubWaybillInfoAdapter(
{
"waybill-1": CDEKWaybillInfo(
waybill_uuid="waybill-1",
url="https://cdek.test/1.pdf",
)
}
)
service = _make_service(repository=repo, waybill_info=waybill_info)
summary = asyncio.run(service.poll_once())
assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0
assert waybill_info.calls == ["waybill-1"]
assert order.cdek_waybill_url == "https://cdek.test/1.pdf"
assert order.cdek_polled_at == _POLLED_AT
def test_poll_once_records_terminal_status_without_waybill() -> None:
order = StoredOrder(order_uuid="o", cdek_order_uuid="cdek-o")
repo = StubRepository([order])
order_info = StubOrderInfoAdapter(
{
"cdek-o": CDEKOrderInfo(
order_uuid="cdek-o",
status_code="INVALID",
waybill_uuid=None,
)
}
)
service = _make_service(repository=repo, order_info=order_info)
summary = asyncio.run(service.poll_once())
assert summary.succeeded == 1
assert order.cdek_order_status == "INVALID"
assert order.cdek_waybill_uuid is None
def test_poll_once_failure_on_one_order_does_not_break_batch() -> None:
failing = StoredOrder(order_uuid="bad", cdek_order_uuid="cdek-bad")
good = StoredOrder(
order_uuid="good",
cdek_order_uuid="cdek-good",
cdek_waybill_uuid="waybill-good",
)
repo = StubRepository([failing, good])
order_info = StubOrderInfoAdapter({"cdek-bad": RuntimeError("cdek down")})
waybill_info = StubWaybillInfoAdapter(
{
"waybill-good": CDEKWaybillInfo(
waybill_uuid="waybill-good",
url="https://cdek.test/good.pdf",
)
}
)
service = _make_service(
repository=repo, order_info=order_info, waybill_info=waybill_info
)
summary = asyncio.run(service.poll_once())
assert summary.processed == 2
assert summary.succeeded == 1
assert summary.failed == 1
assert good.cdek_waybill_url == "https://cdek.test/good.pdf"
def test_run_forever_exits_when_stop_event_is_set() -> None:
repo = StubRepository([])
service = _make_service(repository=repo)
async def run() -> None:
stop_event = asyncio.Event()
stop_event.set()
await asyncio.wait_for(
service.run_forever(interval_seconds=0.01, stop_event=stop_event),
timeout=1.0,
)
asyncio.run(run())