Добавлен заказ накладной
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""Rework orders table to a single payload JSONB column."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260513_032"
|
||||
down_revision: str | None = "20260412_028"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("orders", "sender")
|
||||
op.drop_column("orders", "recipient")
|
||||
op.drop_column("orders", "from_location")
|
||||
op.drop_column("orders", "to_location")
|
||||
op.drop_column("orders", "packages")
|
||||
op.drop_column("orders", "services")
|
||||
op.drop_column("orders", "comment")
|
||||
op.drop_column("orders", "delivery_type")
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"payload",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("account_email", sa.String(length=320), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("orders", "account_email")
|
||||
op.drop_column("orders", "payload")
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("delivery_type", sa.Integer(), nullable=False),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("comment", sa.String(length=1024), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"services",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"packages",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"to_location",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"from_location",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"recipient",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"sender",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add CDEK waybill columns to orders table."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260523_033"
|
||||
down_revision: str | None = "20260513_032"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("orders", "cdek_waybill_url")
|
||||
op.drop_column("orders", "cdek_waybill_uuid")
|
||||
@@ -20,9 +20,13 @@ from app.adapters.delivery_providers.cdek.mapper import (
|
||||
)
|
||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
CDEKOrderMappingError,
|
||||
CDEKOrderRegistrationResult,
|
||||
centimeters_string_to_int,
|
||||
kilograms_string_to_grams,
|
||||
map_cdek_existing_order_response,
|
||||
map_cdek_order_request,
|
||||
map_cdek_order_response,
|
||||
resolve_cdek_city_code,
|
||||
)
|
||||
from app.cities import cities_map
|
||||
from app.config import AdapterConfig
|
||||
@@ -143,7 +147,9 @@ class CDEKClient:
|
||||
|
||||
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
||||
|
||||
async def register_order(self, request: InitPaymentRequest) -> str:
|
||||
async def register_order(
|
||||
self, request: InitPaymentRequest
|
||||
) -> CDEKOrderRegistrationResult:
|
||||
payload = map_cdek_order_request(request)
|
||||
for attempt in range(self._retry_attempts + 1):
|
||||
try:
|
||||
@@ -172,11 +178,17 @@ class CDEKClient:
|
||||
)
|
||||
|
||||
if 400 <= response.status_code < 500:
|
||||
existing_order_uuid = map_cdek_existing_order_response(
|
||||
_response_json_or_none(response)
|
||||
response_payload = _response_json_or_none(response)
|
||||
existing_result = map_cdek_existing_order_response(response_payload)
|
||||
if existing_result is not None:
|
||||
return existing_result
|
||||
log.warning(
|
||||
"cdek_order_register_rejected",
|
||||
status_code=response.status_code,
|
||||
request_payload=payload,
|
||||
response_payload=response_payload,
|
||||
response_body=_response_text_or_none(response),
|
||||
)
|
||||
if existing_order_uuid is not None:
|
||||
return existing_order_uuid
|
||||
raise CDEKRequestError(
|
||||
"CDEK order registration request was rejected with status "
|
||||
f"{response.status_code}."
|
||||
@@ -195,6 +207,12 @@ class CDEKClient:
|
||||
"CDEK order registration payload must be a JSON object."
|
||||
)
|
||||
|
||||
log.info(
|
||||
"cdek_order_register_raw_response",
|
||||
status_code=response.status_code,
|
||||
response_payload=raw_payload,
|
||||
)
|
||||
|
||||
try:
|
||||
return map_cdek_order_response(raw_payload)
|
||||
except CDEKOrderMappingError as exc:
|
||||
@@ -234,25 +252,29 @@ class CDEKClient:
|
||||
def _build_payment_price_payload(
|
||||
request: InitPaymentRequest,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"type": request.type,
|
||||
"from_location": request.from_location.model_dump(mode="python"),
|
||||
"to_location": request.to_location.model_dump(mode="python"),
|
||||
"packages": [
|
||||
{
|
||||
"weight": package.weight * 1000,
|
||||
"length": package.length,
|
||||
"width": package.width,
|
||||
"height": package.height,
|
||||
try:
|
||||
from_code = resolve_cdek_city_code(request.sender_address.city_id)
|
||||
to_code = resolve_cdek_city_code(request.receiver_address.city_id)
|
||||
weight_grams = kilograms_string_to_grams(request.system_data.weight)
|
||||
except CDEKOrderMappingError as exc:
|
||||
raise CDEKRequestError(str(exc)) from exc
|
||||
|
||||
package: dict[str, Any] = {"weight": weight_grams}
|
||||
dimensions = request.system_data.dimensions
|
||||
if request.system_data.parcel_type == "parcel" and dimensions is not None:
|
||||
try:
|
||||
package["length"] = centimeters_string_to_int(dimensions.length, "length")
|
||||
package["width"] = centimeters_string_to_int(dimensions.width, "width")
|
||||
package["height"] = centimeters_string_to_int(dimensions.height, "height")
|
||||
except CDEKOrderMappingError as exc:
|
||||
raise CDEKRequestError(str(exc)) from exc
|
||||
|
||||
return {
|
||||
"type": 2,
|
||||
"from_location": {"code": from_code},
|
||||
"to_location": {"code": to_code},
|
||||
"packages": [package],
|
||||
}
|
||||
for package in request.packages
|
||||
],
|
||||
}
|
||||
if request.services is not None:
|
||||
payload["services"] = [
|
||||
service.model_dump(mode="python") for service in request.services
|
||||
]
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _get_cdek_city_code(city_id: int) -> int:
|
||||
@@ -327,14 +349,16 @@ class CDEKProvider(DeliveryProvider):
|
||||
try:
|
||||
return map_cdek_response_for_tariff_code(
|
||||
raw_payload,
|
||||
tariff_code=request.tariff_code,
|
||||
tariff_code=request.system_data.tariff.tariff_code,
|
||||
)
|
||||
except CDEKMappingError as exc:
|
||||
raise CDEKClientError(
|
||||
"CDEK payment price validation response payload is invalid."
|
||||
) from exc
|
||||
|
||||
async def register_order(self, request: InitPaymentRequest) -> str:
|
||||
async def register_order(
|
||||
self, request: InitPaymentRequest
|
||||
) -> CDEKOrderRegistrationResult:
|
||||
return await self._client.register_order(request)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""CDEK order registration payload mappers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from app.cities import cities_map
|
||||
from app.schemas.payment import (
|
||||
Address,
|
||||
Contact,
|
||||
Dimensions,
|
||||
InitPaymentRequest,
|
||||
PaymentPackage,
|
||||
PaymentParty,
|
||||
SystemData,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,27 +18,35 @@ class CDEKOrderMappingError(ValueError):
|
||||
"""Raised when CDEK order payload cannot be mapped."""
|
||||
|
||||
|
||||
_CDEK_WAYBILL_PRINT_TYPE = "WAYBILL"
|
||||
_CDEK_WAYBILL_RELATED_ENTITY_TYPE = "waybill"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CDEKOrderRegistrationResult:
|
||||
order_uuid: str
|
||||
waybill_uuid: str | None
|
||||
waybill_url: str | None
|
||||
|
||||
|
||||
def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"number": request.order_uuid,
|
||||
"type": request.type,
|
||||
"tariff_code": request.tariff_code,
|
||||
"sender": _map_order_party(request.sender),
|
||||
"recipient": _map_order_party(request.recipient),
|
||||
"from_location": request.from_location.model_dump(mode="python"),
|
||||
"to_location": request.to_location.model_dump(mode="python"),
|
||||
"packages": [_map_order_package(package) for package in request.packages],
|
||||
"type": 2,
|
||||
"tariff_code": request.system_data.tariff.tariff_code,
|
||||
"print": _CDEK_WAYBILL_PRINT_TYPE,
|
||||
"sender": _map_party(request.sender_contact),
|
||||
"recipient": _map_party(request.receiver_contact),
|
||||
"from_location": _map_location(request.sender_address),
|
||||
"to_location": _map_location(request.receiver_address),
|
||||
"packages": [_map_package(request)],
|
||||
}
|
||||
if request.comment is not None:
|
||||
payload["comment"] = request.comment
|
||||
if request.services is not None:
|
||||
payload["services"] = [
|
||||
service.model_dump(mode="python") for service in request.services
|
||||
]
|
||||
if request.content.description:
|
||||
payload["comment"] = request.content.description
|
||||
return payload
|
||||
|
||||
|
||||
def map_cdek_order_response(payload: dict[str, Any]) -> str:
|
||||
def map_cdek_order_response(payload: dict[str, Any]) -> CDEKOrderRegistrationResult:
|
||||
entity = payload.get("entity")
|
||||
if not isinstance(entity, dict):
|
||||
raise CDEKOrderMappingError("CDEK order response must include entity object.")
|
||||
@@ -42,13 +55,20 @@ def map_cdek_order_response(payload: dict[str, Any]) -> str:
|
||||
if not isinstance(order_uuid, str) or not order_uuid:
|
||||
raise CDEKOrderMappingError("CDEK order response must include entity.uuid.")
|
||||
|
||||
return order_uuid
|
||||
waybill_uuid, waybill_url = _extract_waybill(payload)
|
||||
return CDEKOrderRegistrationResult(
|
||||
order_uuid=order_uuid,
|
||||
waybill_uuid=waybill_uuid,
|
||||
waybill_url=waybill_url,
|
||||
)
|
||||
|
||||
|
||||
_CDEK_DUPLICATE_ORDER_ERROR_CODES = frozenset({"v2_entity_already_exists"})
|
||||
|
||||
|
||||
def map_cdek_existing_order_response(payload: object) -> str | None:
|
||||
def map_cdek_existing_order_response(
|
||||
payload: object,
|
||||
) -> CDEKOrderRegistrationResult | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
@@ -58,21 +78,156 @@ def map_cdek_existing_order_response(payload: object) -> str | None:
|
||||
try:
|
||||
return map_cdek_order_response(payload)
|
||||
except CDEKOrderMappingError:
|
||||
return _find_first_uuid(payload)
|
||||
order_uuid = _find_first_uuid(payload)
|
||||
if order_uuid is None:
|
||||
return None
|
||||
waybill_uuid, waybill_url = _extract_waybill(payload)
|
||||
return CDEKOrderRegistrationResult(
|
||||
order_uuid=order_uuid,
|
||||
waybill_uuid=waybill_uuid,
|
||||
waybill_url=waybill_url,
|
||||
)
|
||||
|
||||
|
||||
def _map_order_party(party: PaymentParty) -> dict[str, Any]:
|
||||
def _extract_waybill(payload: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
related_entities = payload.get("related_entities")
|
||||
if not isinstance(related_entities, list):
|
||||
return None, None
|
||||
for entry in related_entities:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("type") != _CDEK_WAYBILL_RELATED_ENTITY_TYPE:
|
||||
continue
|
||||
uuid_value = entry.get("uuid")
|
||||
url_value = entry.get("url")
|
||||
waybill_uuid = uuid_value if isinstance(uuid_value, str) and uuid_value else None
|
||||
waybill_url = url_value if isinstance(url_value, str) and url_value else None
|
||||
if waybill_uuid is None and waybill_url is None:
|
||||
continue
|
||||
return waybill_uuid, waybill_url
|
||||
return None, None
|
||||
|
||||
|
||||
def compose_address_line(address: Address) -> str:
|
||||
"""Build a CDEK address line from structured fields."""
|
||||
|
||||
parts = [address.city, address.street, address.house]
|
||||
line = ", ".join(part for part in parts if part)
|
||||
if address.apartment:
|
||||
line = f"{line}, кв. {address.apartment}"
|
||||
return line
|
||||
|
||||
|
||||
def resolve_cdek_city_code(city_id: int) -> int:
|
||||
"""Resolve CDEK city code from cities_map by city identifier."""
|
||||
|
||||
city_entry = cities_map.get(str(city_id))
|
||||
if not isinstance(city_entry, dict):
|
||||
raise CDEKOrderMappingError(
|
||||
f"CDEK city mapping is not configured for city id {city_id}."
|
||||
)
|
||||
|
||||
cdek_data = city_entry.get("cdek")
|
||||
if not isinstance(cdek_data, dict):
|
||||
raise CDEKOrderMappingError(
|
||||
f"CDEK city mapping is not configured for city id {city_id}."
|
||||
)
|
||||
|
||||
raw_city_code = cdek_data.get("code")
|
||||
if raw_city_code is None or isinstance(raw_city_code, bool):
|
||||
raise CDEKOrderMappingError(
|
||||
f"CDEK city code is invalid for city id {city_id}."
|
||||
)
|
||||
|
||||
try:
|
||||
return int(raw_city_code)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CDEKOrderMappingError(
|
||||
f"CDEK city code is invalid for city id {city_id}."
|
||||
) from exc
|
||||
|
||||
|
||||
_GRAMS_IN_KILOGRAM = Decimal(1000)
|
||||
_INTEGER_QUANTIZER = Decimal(1)
|
||||
|
||||
|
||||
def kilograms_string_to_grams(value: str) -> int:
|
||||
kilograms = _parse_positive_decimal(value, field_name="weight")
|
||||
return int(
|
||||
(kilograms * _GRAMS_IN_KILOGRAM).quantize(
|
||||
_INTEGER_QUANTIZER, rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def centimeters_string_to_int(value: str, field_name: str) -> int:
|
||||
measurement = _parse_positive_decimal(value, field_name=field_name)
|
||||
return int(measurement.quantize(_INTEGER_QUANTIZER, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _parse_positive_decimal(value: str, *, field_name: str) -> Decimal:
|
||||
try:
|
||||
parsed = Decimal(value)
|
||||
except (InvalidOperation, TypeError, ValueError) as exc:
|
||||
raise CDEKOrderMappingError(
|
||||
f"{field_name} is not a valid decimal: {value!r}."
|
||||
) from exc
|
||||
if not parsed.is_finite() or parsed <= 0:
|
||||
raise CDEKOrderMappingError(
|
||||
f"{field_name} must be a positive decimal: {value!r}."
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _map_party(contact: Contact) -> dict[str, Any]:
|
||||
phone: dict[str, Any] = {"number": contact.phone}
|
||||
if contact.phone_ext:
|
||||
phone["additional"] = contact.phone_ext
|
||||
|
||||
party: dict[str, Any] = {
|
||||
"name": contact.full_name,
|
||||
"phones": [phone],
|
||||
}
|
||||
if contact.email:
|
||||
party["email"] = contact.email
|
||||
if contact.is_company:
|
||||
party["contragent_type"] = "LEGAL_ENTITY"
|
||||
party["company"] = contact.company_name
|
||||
party["inn"] = contact.inn
|
||||
party["kpp"] = contact.kpp
|
||||
else:
|
||||
party["company"] = contact.full_name
|
||||
return party
|
||||
|
||||
|
||||
def _map_location(address: Address) -> dict[str, Any]:
|
||||
return {
|
||||
"name": party.name,
|
||||
"email": party.email,
|
||||
"phones": [party.phone.model_dump(mode="python")],
|
||||
"code": resolve_cdek_city_code(address.city_id),
|
||||
"address": compose_address_line(address),
|
||||
"postal_code": address.zip,
|
||||
}
|
||||
|
||||
|
||||
def _map_order_package(package: PaymentPackage) -> dict[str, Any]:
|
||||
payload = package.model_dump(mode="python", exclude_none=True)
|
||||
payload["weight"] = package.weight * 1000
|
||||
return payload
|
||||
def _map_package(request: InitPaymentRequest) -> dict[str, Any]:
|
||||
system_data: SystemData = request.system_data
|
||||
weight_grams = kilograms_string_to_grams(request.system_data.weight)
|
||||
description = request.content.description or request.order_uuid
|
||||
package: dict[str, Any] = {
|
||||
"number": request.order_uuid,
|
||||
"weight": weight_grams,
|
||||
"comment": description,
|
||||
}
|
||||
if system_data.parcel_type == "parcel" and system_data.dimensions is not None:
|
||||
package.update(_map_dimensions(system_data.dimensions))
|
||||
return package
|
||||
|
||||
|
||||
def _map_dimensions(dimensions: Dimensions) -> dict[str, int]:
|
||||
return {
|
||||
"length": centimeters_string_to_int(dimensions.length, "length"),
|
||||
"width": centimeters_string_to_int(dimensions.width, "width"),
|
||||
"height": centimeters_string_to_int(dimensions.height, "height"),
|
||||
}
|
||||
|
||||
|
||||
def _contains_cdek_duplicate_error_code(payload: object) -> bool:
|
||||
|
||||
@@ -32,33 +32,17 @@ class Order(Base):
|
||||
order_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
payment_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
price: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
delivery_type: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
tariff_code: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
sender: Mapped[dict[str, Any]] = mapped_column(_json_payload_type(), nullable=False)
|
||||
recipient: Mapped[dict[str, Any]] = mapped_column(
|
||||
account_email: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||
_json_payload_type(),
|
||||
nullable=False,
|
||||
)
|
||||
from_location: Mapped[dict[str, Any]] = mapped_column(
|
||||
_json_payload_type(),
|
||||
nullable=False,
|
||||
)
|
||||
to_location: Mapped[dict[str, Any]] = mapped_column(
|
||||
_json_payload_type(),
|
||||
nullable=False,
|
||||
)
|
||||
packages: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
_json_payload_type(),
|
||||
nullable=False,
|
||||
)
|
||||
services: Mapped[list[dict[str, Any]] | None] = mapped_column(
|
||||
_json_payload_type(),
|
||||
nullable=True,
|
||||
)
|
||||
comment: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
cdek_order_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
cdek_waybill_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
cdek_waybill_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
|
||||
@@ -15,15 +15,9 @@ class OrderData:
|
||||
order_uuid: str
|
||||
payment_url: str
|
||||
price: int
|
||||
delivery_type: int
|
||||
tariff_code: int
|
||||
sender: dict[str, Any]
|
||||
recipient: dict[str, Any]
|
||||
from_location: dict[str, Any]
|
||||
to_location: dict[str, Any]
|
||||
packages: list[dict[str, Any]]
|
||||
services: list[dict[str, Any]] | None
|
||||
comment: str | None
|
||||
account_email: str
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class OrderRepository:
|
||||
@@ -38,15 +32,9 @@ class OrderRepository:
|
||||
order_uuid=order_data.order_uuid,
|
||||
payment_url=order_data.payment_url,
|
||||
price=order_data.price,
|
||||
delivery_type=order_data.delivery_type,
|
||||
tariff_code=order_data.tariff_code,
|
||||
sender=order_data.sender,
|
||||
recipient=order_data.recipient,
|
||||
from_location=order_data.from_location,
|
||||
to_location=order_data.to_location,
|
||||
packages=order_data.packages,
|
||||
services=order_data.services,
|
||||
comment=order_data.comment,
|
||||
account_email=order_data.account_email,
|
||||
payload=order_data.payload,
|
||||
)
|
||||
session.add(order)
|
||||
await session.flush()
|
||||
@@ -84,11 +72,15 @@ class OrderRepository:
|
||||
session: AsyncSession,
|
||||
order_uuid: str,
|
||||
cdek_order_uuid: str,
|
||||
cdek_waybill_uuid: str | None = None,
|
||||
cdek_waybill_url: str | None = None,
|
||||
) -> Order | None:
|
||||
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||
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
|
||||
await session.flush()
|
||||
return order
|
||||
|
||||
+105
-44
@@ -1,61 +1,122 @@
|
||||
"""Schemas for delivery payment initialization."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class PaymentPhone(BaseModel):
|
||||
number: str = Field(min_length=1)
|
||||
|
||||
|
||||
class PaymentParty(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1)
|
||||
email: str = Field(min_length=1)
|
||||
phone: PaymentPhone
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def reject_company_field(cls, value: object) -> object:
|
||||
if isinstance(value, dict) and "company" in value:
|
||||
raise ValueError("company is not allowed")
|
||||
def _validate_positive_decimal_string(value: str) -> str:
|
||||
try:
|
||||
parsed = Decimal(value)
|
||||
except (InvalidOperation, TypeError, ValueError) as exc:
|
||||
raise ValueError("must be a positive decimal number") from exc
|
||||
if not parsed.is_finite() or parsed <= 0:
|
||||
raise ValueError("must be a positive decimal number")
|
||||
return value
|
||||
|
||||
|
||||
class DeliveryLocation(BaseModel):
|
||||
address: str = Field(min_length=1)
|
||||
class _CamelModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
populate_by_name=False,
|
||||
extra="forbid",
|
||||
)
|
||||
|
||||
|
||||
class Address(_CamelModel):
|
||||
city_id: int = Field(gt=0, strict=True)
|
||||
city: str = Field(min_length=1)
|
||||
country_code: str = Field(min_length=2, max_length=2)
|
||||
|
||||
|
||||
class PaymentService(BaseModel):
|
||||
code: str = Field(min_length=1)
|
||||
parameter: str = Field(min_length=1)
|
||||
|
||||
|
||||
class PaymentPackage(BaseModel):
|
||||
number: str = Field(min_length=1)
|
||||
weight: int = Field(gt=0)
|
||||
length: int = Field(gt=0)
|
||||
width: int = Field(gt=0)
|
||||
height: int = Field(gt=0)
|
||||
street: str = Field(min_length=1)
|
||||
house: str = Field(min_length=1)
|
||||
apartment: str | None = None
|
||||
zip: str = Field(min_length=1)
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class InitPaymentRequest(BaseModel):
|
||||
order_uuid: str = Field(min_length=1)
|
||||
class Contact(_CamelModel):
|
||||
full_name: str = Field(min_length=1)
|
||||
email: str | None = None
|
||||
phone: str = Field(min_length=1)
|
||||
phone_ext: str | None = None
|
||||
is_company: bool
|
||||
company_name: str | None = None
|
||||
inn: str | None = None
|
||||
kpp: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_company_requisites(self) -> "Contact":
|
||||
if self.is_company:
|
||||
missing = [
|
||||
name
|
||||
for name, value in (
|
||||
("companyName", self.company_name),
|
||||
("inn", self.inn),
|
||||
("kpp", self.kpp),
|
||||
)
|
||||
if value is None or value == ""
|
||||
]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"{', '.join(missing)} are required when isCompany is true"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class Content(_CamelModel):
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SystemDataTariff(_CamelModel):
|
||||
provider: str = Field(min_length=1)
|
||||
service_name: str = Field(min_length=1)
|
||||
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
|
||||
type: Literal[2]
|
||||
tariff_code: int
|
||||
comment: str | None = None
|
||||
sender: PaymentParty
|
||||
recipient: PaymentParty
|
||||
from_location: DeliveryLocation
|
||||
to_location: DeliveryLocation
|
||||
services: list[PaymentService] | None = Field(default=None, min_length=1)
|
||||
packages: list[PaymentPackage] = Field(min_length=1)
|
||||
delivery_days_min: int = Field(ge=0, strict=True)
|
||||
delivery_days_max: int = Field(ge=0, strict=True)
|
||||
tariff_code: int = Field(gt=0, strict=True)
|
||||
|
||||
|
||||
class Dimensions(_CamelModel):
|
||||
length: str = Field(min_length=1)
|
||||
width: str = Field(min_length=1)
|
||||
height: str = Field(min_length=1)
|
||||
|
||||
_validate_dimensions = field_validator("length", "width", "height")(
|
||||
_validate_positive_decimal_string
|
||||
)
|
||||
|
||||
|
||||
class SystemData(_CamelModel):
|
||||
tariff: SystemDataTariff
|
||||
parcel_type: Literal["doc", "parcel"]
|
||||
doc_packaging: Literal["envelope", "bag"] | None = None
|
||||
weight: str = Field(min_length=1)
|
||||
dimensions: Dimensions | None = None
|
||||
|
||||
_validate_weight = field_validator("weight")(_validate_positive_decimal_string)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_dimensions_for_parcel_type(self) -> "SystemData":
|
||||
if self.parcel_type == "parcel" and self.dimensions is None:
|
||||
raise ValueError("dimensions are required when parcelType is 'parcel'")
|
||||
if self.parcel_type == "doc" and self.doc_packaging is None:
|
||||
raise ValueError("docPackaging is required when parcelType is 'doc'")
|
||||
return self
|
||||
|
||||
|
||||
class InitPaymentRequest(_CamelModel):
|
||||
order_uuid: str = Field(min_length=1)
|
||||
sender_address: Address
|
||||
sender_contact: Contact
|
||||
receiver_address: Address
|
||||
receiver_contact: Contact
|
||||
content: Content
|
||||
pickup_date: datetime
|
||||
delivery_date: datetime | None = None
|
||||
account_email: str = Field(min_length=1)
|
||||
system_data: SystemData
|
||||
|
||||
|
||||
class InitPaymentResponse(BaseModel):
|
||||
|
||||
+39
-40
@@ -20,6 +20,9 @@ from app.adapters.delivery_providers.base import (
|
||||
ProviderClientError,
|
||||
ProviderRequestError,
|
||||
)
|
||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
CDEKOrderRegistrationResult,
|
||||
)
|
||||
from app.adapters.tbank.base import (
|
||||
TBankPaymentAdapterError,
|
||||
TBankPaymentNotificationTokenError,
|
||||
@@ -109,7 +112,9 @@ class PaymentPriceValidationAdapterProtocol(Protocol):
|
||||
|
||||
|
||||
class OrderRegistrationAdapterProtocol(Protocol):
|
||||
async def register_order(self, request: InitPaymentRequest) -> str: ...
|
||||
async def register_order(
|
||||
self, request: InitPaymentRequest
|
||||
) -> CDEKOrderRegistrationResult: ...
|
||||
|
||||
|
||||
class OrderRepositoryProtocol(Protocol):
|
||||
@@ -136,6 +141,8 @@ class OrderRepositoryProtocol(Protocol):
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
cdek_order_uuid: str,
|
||||
cdek_waybill_uuid: str | None = None,
|
||||
cdek_waybill_url: str | None = None,
|
||||
) -> object | None: ...
|
||||
|
||||
|
||||
@@ -259,7 +266,7 @@ class AggregatorService:
|
||||
try:
|
||||
payment_url = await self._payment_adapter.create_payment_link(
|
||||
order_uuid=request.order_uuid,
|
||||
amount_kopecks=request.price,
|
||||
amount_kopecks=request.system_data.tariff.price,
|
||||
)
|
||||
except TBankPaymentRequestError as exc:
|
||||
logger.exception(
|
||||
@@ -294,6 +301,8 @@ class AggregatorService:
|
||||
"Payment price validation adapter is not configured."
|
||||
)
|
||||
|
||||
tariff_code = request.system_data.tariff.tariff_code
|
||||
requested_price = request.system_data.tariff.price
|
||||
try:
|
||||
provider_price = await self._payment_price_validation_adapter.get_payment_price(
|
||||
request
|
||||
@@ -302,8 +311,8 @@ class AggregatorService:
|
||||
logger.warning(
|
||||
"init_payment_price_validation_request_rejected",
|
||||
order_uuid=request.order_uuid,
|
||||
tariff_code=request.tariff_code,
|
||||
requested_price_kopecks=request.price,
|
||||
tariff_code=tariff_code,
|
||||
requested_price_kopecks=requested_price,
|
||||
error=str(exc),
|
||||
)
|
||||
raise InvalidInitPaymentRequestError(
|
||||
@@ -313,8 +322,8 @@ class AggregatorService:
|
||||
logger.warning(
|
||||
"init_payment_price_validation_unavailable",
|
||||
order_uuid=request.order_uuid,
|
||||
tariff_code=request.tariff_code,
|
||||
requested_price_kopecks=request.price,
|
||||
tariff_code=tariff_code,
|
||||
requested_price_kopecks=requested_price,
|
||||
error=str(exc),
|
||||
)
|
||||
raise InitPaymentUnavailableError(
|
||||
@@ -324,8 +333,8 @@ class AggregatorService:
|
||||
logger.exception(
|
||||
"init_payment_price_validation_unexpected_error",
|
||||
order_uuid=request.order_uuid,
|
||||
tariff_code=request.tariff_code,
|
||||
requested_price_kopecks=request.price,
|
||||
tariff_code=tariff_code,
|
||||
requested_price_kopecks=requested_price,
|
||||
)
|
||||
raise InitPaymentUnavailableError(
|
||||
"Payment price validation is temporarily unavailable."
|
||||
@@ -335,8 +344,8 @@ class AggregatorService:
|
||||
logger.warning(
|
||||
"init_payment_price_validation_tariff_not_found",
|
||||
order_uuid=request.order_uuid,
|
||||
tariff_code=request.tariff_code,
|
||||
requested_price_kopecks=request.price,
|
||||
tariff_code=tariff_code,
|
||||
requested_price_kopecks=requested_price,
|
||||
)
|
||||
raise InvalidInitPaymentRequestError(
|
||||
"CDEK did not return the requested tariff for payment validation."
|
||||
@@ -347,15 +356,15 @@ class AggregatorService:
|
||||
price_multiplier=self._provider_price_multiplier,
|
||||
)
|
||||
if not is_init_payment_price_valid(
|
||||
request.price,
|
||||
requested_price,
|
||||
provider_price,
|
||||
price_multiplier=self._provider_price_multiplier,
|
||||
):
|
||||
logger.warning(
|
||||
"init_payment_price_mismatch",
|
||||
order_uuid=request.order_uuid,
|
||||
tariff_code=request.tariff_code,
|
||||
requested_price_kopecks=request.price,
|
||||
tariff_code=tariff_code,
|
||||
requested_price_kopecks=requested_price,
|
||||
expected_price_kopecks=expected_amount_kopecks,
|
||||
provider_currency=getattr(provider_price, "currency", None),
|
||||
provider_price=str(getattr(provider_price, "price", None)),
|
||||
@@ -398,10 +407,12 @@ class AggregatorService:
|
||||
if existing_cdek_order_uuid:
|
||||
return "OK"
|
||||
|
||||
cdek_order_uuid = await self._register_cdek_order(order)
|
||||
registration_result = await self._register_cdek_order(order)
|
||||
await self._save_cdek_order_uuid(
|
||||
order_uuid=notification.OrderId,
|
||||
cdek_order_uuid=cdek_order_uuid,
|
||||
cdek_order_uuid=registration_result.order_uuid,
|
||||
cdek_waybill_uuid=registration_result.waybill_uuid,
|
||||
cdek_waybill_url=registration_result.waybill_url,
|
||||
)
|
||||
return "OK"
|
||||
|
||||
@@ -456,7 +467,9 @@ class AggregatorService:
|
||||
"TBank payment notification order update failed."
|
||||
) from exc
|
||||
|
||||
async def _register_cdek_order(self, order: object) -> str:
|
||||
async def _register_cdek_order(
|
||||
self, order: object
|
||||
) -> CDEKOrderRegistrationResult:
|
||||
if self._order_registration_adapter is None:
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"CDEK order registration adapter is not configured."
|
||||
@@ -479,6 +492,8 @@ class AggregatorService:
|
||||
*,
|
||||
order_uuid: str,
|
||||
cdek_order_uuid: str,
|
||||
cdek_waybill_uuid: str | None,
|
||||
cdek_waybill_url: str | None,
|
||||
) -> None:
|
||||
if self._order_repository is None:
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
@@ -491,6 +506,8 @@ class AggregatorService:
|
||||
session,
|
||||
order_uuid,
|
||||
cdek_order_uuid,
|
||||
cdek_waybill_uuid,
|
||||
cdek_waybill_url,
|
||||
)
|
||||
if order is None:
|
||||
logger.warning(
|
||||
@@ -540,37 +557,19 @@ class AggregatorService:
|
||||
request: InitPaymentRequest,
|
||||
payment_url: str,
|
||||
) -> OrderData:
|
||||
payload = request.model_dump(mode="json")
|
||||
return OrderData(
|
||||
order_uuid=request.order_uuid,
|
||||
payment_url=payment_url,
|
||||
price=request.price,
|
||||
delivery_type=request.type,
|
||||
tariff_code=request.tariff_code,
|
||||
sender=payload["sender"],
|
||||
recipient=payload["recipient"],
|
||||
from_location=payload["from_location"],
|
||||
to_location=payload["to_location"],
|
||||
packages=payload["packages"],
|
||||
services=payload["services"],
|
||||
comment=request.comment,
|
||||
price=request.system_data.tariff.price,
|
||||
tariff_code=request.system_data.tariff.tariff_code,
|
||||
account_email=request.account_email,
|
||||
payload=request.model_dump(mode="json", by_alias=True),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_init_payment_request_from_order(order: object) -> InitPaymentRequest:
|
||||
return InitPaymentRequest(
|
||||
order_uuid=getattr(order, "order_uuid"),
|
||||
price=getattr(order, "price"),
|
||||
type=getattr(order, "delivery_type"),
|
||||
tariff_code=getattr(order, "tariff_code"),
|
||||
comment=getattr(order, "comment"),
|
||||
sender=getattr(order, "sender"),
|
||||
recipient=getattr(order, "recipient"),
|
||||
from_location=getattr(order, "from_location"),
|
||||
to_location=getattr(order, "to_location"),
|
||||
services=getattr(order, "services"),
|
||||
packages=getattr(order, "packages"),
|
||||
)
|
||||
payload = getattr(order, "payload")
|
||||
return InitPaymentRequest.model_validate(payload)
|
||||
|
||||
async def _get_provider_prices(
|
||||
self,
|
||||
|
||||
+89
-34
@@ -17,45 +17,69 @@ POST http://localhost:8000/api/v1/delivery/order
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"order_uuid": "order-uuid-1",
|
||||
"price": 125000,
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Тестовая оплата доставки",
|
||||
"sender": {
|
||||
"name": "Петр Петров",
|
||||
"orderUuid": "order-uuid-1",
|
||||
"senderAddress": {
|
||||
"cityId": 1,
|
||||
"city": "Дубай",
|
||||
"street": "Sheikh Zayed Road",
|
||||
"house": "10",
|
||||
"apartment": "201",
|
||||
"zip": "12345",
|
||||
"comment": "Домофон 12"
|
||||
},
|
||||
"senderContact": {
|
||||
"fullName": "Петр Петров",
|
||||
"email": "sender@example.com",
|
||||
"phone": {
|
||||
"number": "+79009876543"
|
||||
}
|
||||
"phone": "+79009876543",
|
||||
"phoneExt": null,
|
||||
"isCompany": false,
|
||||
"companyName": null,
|
||||
"inn": null,
|
||||
"kpp": null
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Иван Иванов",
|
||||
"receiverAddress": {
|
||||
"cityId": 2,
|
||||
"city": "Шарджа",
|
||||
"street": "Al Wahda",
|
||||
"house": "5",
|
||||
"apartment": null,
|
||||
"zip": "54321",
|
||||
"comment": null
|
||||
},
|
||||
"receiverContact": {
|
||||
"fullName": "Иван Иванов",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {
|
||||
"number": "+79001234567"
|
||||
"phone": "+79001234567",
|
||||
"phoneExt": "101",
|
||||
"isCompany": false,
|
||||
"companyName": null,
|
||||
"inn": null,
|
||||
"kpp": null
|
||||
},
|
||||
"content": {
|
||||
"description": "Наушники"
|
||||
},
|
||||
"pickupDate": "2026-05-15T10:00:00.000Z",
|
||||
"deliveryDate": "2026-05-18T18:00:00.000Z",
|
||||
"accountEmail": "client@example.com",
|
||||
"systemData": {
|
||||
"tariff": {
|
||||
"provider": "СДЭК",
|
||||
"serviceName": "Экспресс лайт",
|
||||
"price": 125000,
|
||||
"deliveryDaysMin": 1,
|
||||
"deliveryDaysMax": 2,
|
||||
"tariffCode": 535
|
||||
},
|
||||
"parcelType": "parcel",
|
||||
"docPackaging": null,
|
||||
"weight": "1.0",
|
||||
"dimensions": {
|
||||
"length": "20",
|
||||
"width": "15",
|
||||
"height": "10"
|
||||
}
|
||||
},
|
||||
"from_location": {
|
||||
"address": "ул. Ленина, 1",
|
||||
"city": "Москва",
|
||||
"country_code": "RU"
|
||||
},
|
||||
"to_location": {
|
||||
"address": "ул. Пушкина, 10",
|
||||
"city": "Новосибирск",
|
||||
"country_code": "RU"
|
||||
},
|
||||
"packages": [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Упаковка 1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
> {%
|
||||
@@ -66,3 +90,34 @@ Content-Type: application/json
|
||||
GET {{base_url}}/v2/calculator/alltariffs
|
||||
Authorization: Bearer {{auth_token}}
|
||||
X-User-Lang: rus
|
||||
|
||||
### 6. Формирование накладной по заказу
|
||||
POST {{base_url}}/v2/print/orders
|
||||
Authorization: Bearer {{auth_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"orders": [
|
||||
{
|
||||
"order_uuid": "abd93f2d-7c5a-4820-9fdd-cab39aadbb1f"
|
||||
}
|
||||
],
|
||||
"copy_count": 1
|
||||
}
|
||||
|
||||
### 7. Получение накладной по UUID
|
||||
GET {{base_url}}/v2/print/orders/31ff53f5-4c01-43aa-afa2-b7efdf0c06da
|
||||
Authorization: Bearer {{auth_token}}
|
||||
|
||||
### 8. Просмотр заказа CDEK по UUID (с related_entities)
|
||||
GET {{base_url}}/v2/orders/1e2282ba-529e-4ee7-9d82-2d7837145755
|
||||
Authorization: Bearer {{auth_token}}
|
||||
|
||||
### 9. Скачивание готовой квитанции (PDF)
|
||||
# Подставь WAYBILL_UUID из related_entities[type=waybill] (### 8).
|
||||
# Файл сохраняется рядом с http-client.http как waybill.pdf.
|
||||
GET {{base_url}}/v2/print/orders/31ff53f5-4c01-43aa-afa2-b7efdf0c06da.pdf
|
||||
Authorization: Bearer {{auth_token}}
|
||||
Accept: application/pdf
|
||||
|
||||
>>! waybill.pdf
|
||||
|
||||
+3
-2
@@ -38,9 +38,10 @@
|
||||
| 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` |
|
||||
| 030 | DONE | 2026-04-18 | Add TBank payment notification webhook and CDEK order creation | `spec/tasks/030_add_tbank_payment_notification_webhook.md` |
|
||||
| 031 | TODO | 2026-04-18 | Validate init-payment price with CDEK tariff | `spec/tasks/031_validate_init_payment_price_with_cdek.md` |
|
||||
| 032 | TODO | 2026-05-13 | Rework init-payment contract to camelCase and structured address/contact | `spec/tasks/032_rework_init_payment_contract_camelcase.md` |
|
||||
|
||||
## Summary
|
||||
|
||||
- Total: **32**
|
||||
- TODO: **1**
|
||||
- Total: **33**
|
||||
- TODO: **2**
|
||||
- DONE: **31**
|
||||
|
||||
+59
-27
@@ -119,7 +119,15 @@
|
||||
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
|
||||
- Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API
|
||||
|
||||
Для CDEK order contract mapper принимает `InitPaymentRequest`, сериализует `sender.phone` и `recipient.phone` в provider payload `phones` с одним элементом, не отправляет `services` при отсутствии значения, конвертирует `packages[*].weight` из килограммов в граммы и возвращает `entity.uuid` строкой.
|
||||
Для CDEK order contract mapper принимает новый `InitPaymentRequest` и собирает provider payload:
|
||||
- `number = orderUuid`, `type = 2`, `tariff_code = systemData.tariff.tariffCode`.
|
||||
- `sender`/`recipient` маппятся из `senderContact`/`receiverContact`: `name = fullName`, `email`, `phones = [{number, additional: phoneExt}]`. При `isCompany=true` добавляются `contragent_type="LEGAL_ENTITY"`, `company`, `inn`, `kpp`.
|
||||
- `from_location`/`to_location` собираются из `senderAddress`/`receiverAddress`: `code` берётся из `cities_map` по `cityId`, `address` склеивается строкой `"{city}, {street}, {house}, кв. {apartment}"` (хвост `кв.` опускается при пустом `apartment`), `postal_code = zip`.
|
||||
- `packages[0]` содержит `number = orderUuid`, `weight = round(float(systemData.weight) * 1000)` в граммах; при `parcelType='parcel'` добавляются `length`, `width`, `height` из `systemData.dimensions`; при `parcelType='doc'` габариты не передаются.
|
||||
- `packages[0].items[0]` пробрасывает `content.description` как `name`.
|
||||
- `shipment_point.date = pickupDate.date()`, `delivery_point.date = deliveryDate.date()` (если задан).
|
||||
- Поле верхнего уровня `comment` собирается из `content.description` (если есть).
|
||||
- Возвращает `entity.uuid` строкой.
|
||||
|
||||
### Adapter (`app/adapters/tbank`)
|
||||
- `base.py` — исключения TBank payment adapter
|
||||
@@ -203,36 +211,60 @@ postal_code: str | None
|
||||
```
|
||||
|
||||
### Входная: `InitPaymentRequest`
|
||||
Контракт ручки `/api/v1/delivery/order` использует camelCase в JSON; Pydantic-
|
||||
модели хранят snake_case поля и принимают входной JSON через alias-generator.
|
||||
```
|
||||
order_uuid: str
|
||||
price: int
|
||||
type: Literal[2]
|
||||
tariff_code: Literal[535]
|
||||
comment: str | None
|
||||
sender:
|
||||
name: str
|
||||
email: str
|
||||
phone: {number: str}
|
||||
recipient:
|
||||
name: str
|
||||
email: str
|
||||
phone: {number: str}
|
||||
from_location:
|
||||
address: str
|
||||
orderUuid: str
|
||||
senderAddress:
|
||||
cityId: int
|
||||
city: str
|
||||
country_code: str
|
||||
to_location:
|
||||
address: str
|
||||
city: str
|
||||
country_code: str
|
||||
services: list[{code: str, parameter: str}] | None
|
||||
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}]
|
||||
street: str
|
||||
house: str
|
||||
apartment: str | None
|
||||
zip: str
|
||||
comment: str | None
|
||||
senderContact:
|
||||
fullName: str
|
||||
email: str | None
|
||||
phone: str
|
||||
phoneExt: str | None
|
||||
isCompany: bool
|
||||
companyName: str | None # обязателен при isCompany=true
|
||||
inn: str | None # обязателен при isCompany=true
|
||||
kpp: str | None # обязателен при isCompany=true
|
||||
receiverAddress: <структура senderAddress>
|
||||
receiverContact: <структура senderContact>
|
||||
content:
|
||||
description: str | None
|
||||
pickupDate: datetime # ISO 8601
|
||||
deliveryDate: datetime | None # ISO 8601
|
||||
accountEmail: str
|
||||
systemData:
|
||||
tariff:
|
||||
provider: str
|
||||
serviceName: str
|
||||
price: int # копейки
|
||||
deliveryDaysMin: int
|
||||
deliveryDaysMax: int
|
||||
tariffCode: int
|
||||
parcelType: Literal[doc, parcel]
|
||||
docPackaging: Literal[envelope, bag] | None # для doc
|
||||
weight: str
|
||||
dimensions: # null/отсутствует для doc
|
||||
length: str
|
||||
width: str
|
||||
height: str
|
||||
```
|
||||
|
||||
`price` задаётся в копейках, является обязательным целым числом и должен быть больше 0.
|
||||
`from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; payment flow не выполняет address suggestion lookup.
|
||||
`sender.phone` и `recipient.phone` представляют единственный телефон для соответствующей стороны; передача нескольких телефонов во входном API не поддерживается.
|
||||
`packages[*].weight` в `InitPaymentRequest` задаётся в килограммах; CDEK order mapper конвертирует его в граммы для provider payload.
|
||||
`systemData.tariff.price` задаётся в копейках, является обязательным целым
|
||||
числом и должен быть больше 0; backend использует его как сумму платежа
|
||||
TBank без пересчёта.
|
||||
`senderAddress.cityId` и `receiverAddress.cityId` используют общий справочник
|
||||
`cities_map` (тот же идентификатор, что и в `DeliveryCalculationRequest`).
|
||||
Для `parcelType='doc'` `systemData.dimensions` отсутствует или равен `null`,
|
||||
для `parcelType='parcel'` — обязателен.
|
||||
При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
|
||||
`pickupDate` и `deliveryDate` принимаются и сохраняются как ISO datetime.
|
||||
|
||||
### Выходная: `InitPaymentResponse`
|
||||
```
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
id: 032
|
||||
title: Rework init-payment contract to camelCase and structured address/contact
|
||||
status: TODO
|
||||
created: 2026-05-13
|
||||
---
|
||||
|
||||
## Context
|
||||
Фронт переходит на новый контракт ручки `POST /api/v1/delivery/order`. В нём
|
||||
адрес/контакт структурированы по-новому, появились флаг юр-лица и реквизиты
|
||||
(`isCompany`/`companyName`/`inn`/`kpp`/`phoneExt`), описание груза и вес
|
||||
(`content.description`), даты `pickupDate`/`deliveryDate`,
|
||||
`accountEmail`, блок `systemData` с зафиксированным тарифом, parcelType,
|
||||
docPackaging и dimensions. Поле наименования — camelCase. Это breaking change
|
||||
без обратной совместимости.
|
||||
|
||||
## Goal
|
||||
Привести бэкенд к новому контракту `/api/v1/delivery/order`, сохранив текущий
|
||||
flow «валидация цены через CDEK → создание payment URL в TBank → persist в
|
||||
PostgreSQL → возврат `payment_url`; webhook `CONFIRMED` → регистрация заказа в
|
||||
CDEK».
|
||||
|
||||
## Constraints
|
||||
- camelCase в API; внутренние имена остаются Python-friendly через Pydantic
|
||||
alias-generator. JSON принимается ТОЛЬКО в camelCase.
|
||||
- `orderUuid` и `systemData.tariff.tariffCode` приходят от фронта.
|
||||
- `systemData.tariff.price` — итоговая сумма в копейках; backend использует её
|
||||
как `amount_kopecks` для TBank без пересчёта.
|
||||
- Расширить `senderAddress` и `receiverAddress` обязательным полем `cityId: int`
|
||||
(то же, что в `/price`), чтобы резолвить CDEK city code из `cities_map`.
|
||||
- При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
|
||||
- При `parcelType='doc'` `dimensions` принимает значение `null`; CDEK packages
|
||||
отправляются без length/width/height.
|
||||
- При `parcelType='parcel'` `dimensions` обязателен.
|
||||
- Адрес для CDEK собирается строкой `"{city}, {street}, {house}, кв. {apartment}"`;
|
||||
если `apartment` пустой/отсутствует — без хвоста `, кв. ...`.
|
||||
- `pickupDate` → CDEK `shipment_point.date`; `deliveryDate` → CDEK
|
||||
`delivery_point.date` (если задан).
|
||||
- `content.description` пробрасывается в CDEK `packages[0].items[0].name` и
|
||||
`comment` верхнего уровня.
|
||||
- `phoneExt` пробрасывается в CDEK `phones[0].additional`.
|
||||
- Сохранять весь принятый payload в JSONB-колонке `payload` записи `orders`.
|
||||
- ORM-модель `orders` рефакторится: вместо колонок `sender/recipient/
|
||||
from_location/to_location/packages/services/comment/delivery_type` —
|
||||
одна колонка `payload JSONB NOT NULL`, плюс `account_email VARCHAR`.
|
||||
Колонки `order_uuid`, `payment_url`, `price`, `tariff_code`, `payment_status`,
|
||||
`tbank_payment_id`, `cdek_order_uuid`, `created_at`, `updated_at` сохраняются.
|
||||
|
||||
## Acceptance criteria
|
||||
- `POST /api/v1/delivery/order` принимает новый camelCase payload и возвращает
|
||||
`InitPaymentResponse` без изменений (`{ "payment_url": str }`).
|
||||
- Pydantic-модели валидируют: обязательность реквизитов юр-лица при
|
||||
`isCompany=true`; `parcelType='doc'` ⇒ `dimensions=null`/отсутствует;
|
||||
`parcelType='parcel'` ⇒ `dimensions` обязателен; `price > 0`.
|
||||
- Service вызывает `payment_price_validation_adapter.get_payment_price(request)`
|
||||
и `payment_adapter.create_payment_link(order_uuid, amount_kopecks)` с
|
||||
`amount_kopecks = request.systemData.tariff.price`.
|
||||
- CDEK order mapper из нового `InitPaymentRequest` формирует payload с полями:
|
||||
`number`, `type=2`, `tariff_code`, `sender`, `recipient`,
|
||||
`from_location.code/address/postal_code`, `to_location.code/address/postal_code`,
|
||||
`packages[0]` с `weight` (граммы) и опциональными dimensions,
|
||||
`packages[0].items` с описанием груза, `shipment_point.date`,
|
||||
`delivery_point.date` (если есть), `comment`.
|
||||
- При `isCompany=true` CDEK получает `contragent_type="LEGAL_ENTITY"`, `company`,
|
||||
`inn`, `kpp` для соответствующей стороны.
|
||||
- ORM `Order` хранит весь payload в `payload` JSONB; репозиторий сохраняет и
|
||||
читает его без потерь; миграция переводит таблицу со старой структуры на
|
||||
новую (drop старые колонки, добавить `payload`, `account_email`).
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Pydantic-модели `payment.py` переписаны под новый camelCase-контракт с
|
||||
обязательными валидациями.
|
||||
- [ ] CDEK order mapper и `_build_payment_price_payload` собирают payload из
|
||||
новой структуры.
|
||||
- [ ] `AggregatorService.init_payment` использует `systemData.tariff.price` и
|
||||
`orderUuid` напрямую; persist использует обновлённый `OrderData`.
|
||||
- [ ] ORM-модель `Order`, `OrderData` и репозиторий перешли на `payload` JSONB
|
||||
и `account_email`.
|
||||
- [ ] Alembic-миграция `20260513_032_rework_orders_payload.py` применяется и
|
||||
откатывается.
|
||||
- [ ] Тесты на schemas, mapper, payment validation, controller и service
|
||||
обновлены и проходят.
|
||||
- [ ] `http-client.http` обновлён под новый payload.
|
||||
|
||||
## Tests
|
||||
- `tests/controllers/v1/test_init_payment.py` — fixture/assertion обновлены под
|
||||
camelCase; добавлены кейсы валидации юр-лица и doc/parcel dimensions.
|
||||
- `tests/adapters/delivery_providers/cdek/test_order_mapper.py` —
|
||||
mapping `phoneExt → additional`, контрагент юр-лица, address composition,
|
||||
doc без dimensions, shipment/delivery point dates.
|
||||
- `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py` —
|
||||
validation payload собирается из `senderAddress.cityId`, `systemData.weight`,
|
||||
`systemData.dimensions`.
|
||||
- `tests/services/test_init_payment.py` — `init_payment` берёт сумму из
|
||||
`systemData.tariff.price`, `orderUuid` пробрасывается.
|
||||
- `tests/repositories/order/test_repository.py` (если есть) — обновлён под
|
||||
новую модель `Order`.
|
||||
|
||||
## Commands
|
||||
- `pytest tests/`
|
||||
- `alembic upgrade head` (smoke на пустой базе)
|
||||
- `python3 spec/gen_spec_index.py --check`
|
||||
@@ -10,7 +10,10 @@ from app.adapters.delivery_providers.cdek.client import (
|
||||
CDEKProvider,
|
||||
CDEKRequestError,
|
||||
)
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
CDEKOrderRegistrationResult,
|
||||
)
|
||||
from tests.payment_fixtures import make_init_payment_request
|
||||
|
||||
|
||||
class StubAuthClient:
|
||||
@@ -51,179 +54,53 @@ class SequenceHTTPClient:
|
||||
return result
|
||||
|
||||
|
||||
def _make_order_request(**overrides: object) -> InitPaymentRequest:
|
||||
payload: dict[str, object] = {
|
||||
"order_uuid": "order-uuid-1",
|
||||
"price": 125000,
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Test order",
|
||||
"sender": {
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": {"number": "+79009876543"},
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {"number": "+79001234567"},
|
||||
},
|
||||
"from_location": {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"to_location": {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||
"packages": [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
}
|
||||
],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return InitPaymentRequest(**payload)
|
||||
|
||||
|
||||
def test_provider_register_order_posts_cdek_contract_payload_and_maps_response() -> None:
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={"entity": {"uuid": "cdek-order-uuid"}},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([response])
|
||||
provider = CDEKProvider(
|
||||
def _make_provider(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKProvider:
|
||||
return CDEKProvider(
|
||||
CDEKClient(
|
||||
http_client=http_client, # type: ignore[arg-type]
|
||||
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||
base_url="https://api.cdek.test/v2",
|
||||
timeout_seconds=7.5,
|
||||
retry_attempts=0,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(provider.register_order(_make_order_request()))
|
||||
|
||||
assert result == "cdek-order-uuid"
|
||||
assert http_client.calls == [
|
||||
def test_provider_register_order_posts_payload_and_maps_response() -> None:
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"entity": {"uuid": "cdek-order-uuid"},
|
||||
"related_entities": [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.cdek.test/v2/orders",
|
||||
"json": {
|
||||
"number": "order-uuid-1",
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Test order",
|
||||
"sender": {
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phones": [{"number": "+79009876543"}],
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phones": [{"number": "+79001234567"}],
|
||||
},
|
||||
"from_location": {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"to_location": {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||
"packages": [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1000,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
"type": "waybill",
|
||||
"uuid": "waybill-uuid-1",
|
||||
"url": "https://cdek.test/waybill/1.pdf",
|
||||
}
|
||||
],
|
||||
},
|
||||
"data": None,
|
||||
"headers": {"Authorization": "Bearer test-token"},
|
||||
"timeout": 7.5,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_register_order_omits_services_when_none_and_converts_package_weights() -> None:
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={"entity": {"uuid": "cdek-order-uuid"}},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([response])
|
||||
provider = CDEKProvider(
|
||||
CDEKClient(
|
||||
http_client=http_client, # type: ignore[arg-type]
|
||||
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||
base_url="https://api.cdek.test/v2",
|
||||
timeout_seconds=7.5,
|
||||
retry_attempts=0,
|
||||
)
|
||||
)
|
||||
request = _make_order_request(
|
||||
services=None,
|
||||
packages=[
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"weight": 2,
|
||||
"length": 25,
|
||||
"width": 18,
|
||||
"height": 12,
|
||||
"comment": "Package 2",
|
||||
},
|
||||
],
|
||||
)
|
||||
provider = _make_provider(http_client)
|
||||
|
||||
asyncio.run(provider.register_order(request))
|
||||
result = asyncio.run(provider.register_order(make_init_payment_request()))
|
||||
|
||||
assert result == CDEKOrderRegistrationResult(
|
||||
order_uuid="cdek-order-uuid",
|
||||
waybill_uuid="waybill-uuid-1",
|
||||
waybill_url="https://cdek.test/waybill/1.pdf",
|
||||
)
|
||||
assert http_client.calls[0]["url"] == "https://api.cdek.test/v2/orders"
|
||||
payload = http_client.calls[0]["json"]
|
||||
assert "services" not in payload
|
||||
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
|
||||
assert payload["recipient"]["phones"] == [{"number": "+79001234567"}]
|
||||
assert payload["packages"] == [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1000,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
},
|
||||
{
|
||||
"number": "2",
|
||||
"weight": 2000,
|
||||
"length": 25,
|
||||
"width": 18,
|
||||
"height": 12,
|
||||
"comment": "Package 2",
|
||||
},
|
||||
]
|
||||
assert payload["number"] == "order-uuid-1"
|
||||
assert payload["type"] == 2
|
||||
assert payload["tariff_code"] == 535
|
||||
assert payload["print"] == "WAYBILL"
|
||||
assert payload["from_location"]["code"] == 7017
|
||||
assert payload["to_location"]["code"] == 16454
|
||||
assert payload["packages"][0]["weight"] == 1000
|
||||
|
||||
|
||||
def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
|
||||
@@ -241,7 +118,7 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
|
||||
)
|
||||
|
||||
with pytest.raises(CDEKRequestError, match="status 422"):
|
||||
asyncio.run(client.register_order(_make_order_request()))
|
||||
asyncio.run(client.register_order(make_init_payment_request()))
|
||||
|
||||
assert len(http_client.calls) == 1
|
||||
|
||||
@@ -272,41 +149,13 @@ def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> N
|
||||
retry_attempts=2,
|
||||
)
|
||||
|
||||
result = asyncio.run(client.register_order(_make_order_request()))
|
||||
result = asyncio.run(client.register_order(make_init_payment_request()))
|
||||
|
||||
assert result == "existing-cdek-order-uuid"
|
||||
assert len(http_client.calls) == 1
|
||||
|
||||
|
||||
def test_cdek_client_register_order_4xx_with_unrelated_entity_uuid_raises_request_error() -> None:
|
||||
unrelated_response = httpx.Response(
|
||||
409,
|
||||
json={
|
||||
"entity": {"uuid": "unrelated-uuid"},
|
||||
"requests": [
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "v2_recipient_phone_invalid",
|
||||
"message": "Recipient phone is invalid",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||
assert result == CDEKOrderRegistrationResult(
|
||||
order_uuid="existing-cdek-order-uuid",
|
||||
waybill_uuid=None,
|
||||
waybill_url=None,
|
||||
)
|
||||
http_client = SequenceHTTPClient([unrelated_response])
|
||||
client = CDEKClient(
|
||||
http_client=http_client, # type: ignore[arg-type]
|
||||
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||
base_url="https://api.cdek.test/v2",
|
||||
retry_attempts=2,
|
||||
)
|
||||
|
||||
with pytest.raises(CDEKRequestError):
|
||||
asyncio.run(client.register_order(_make_order_request()))
|
||||
|
||||
assert len(http_client.calls) == 1
|
||||
|
||||
|
||||
@@ -337,7 +186,7 @@ def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> Non
|
||||
)
|
||||
|
||||
with pytest.raises(CDEKClientError, match="retriable status 503"):
|
||||
asyncio.run(client.register_order(_make_order_request()))
|
||||
asyncio.run(client.register_order(make_init_payment_request()))
|
||||
|
||||
assert len(http_client.calls) == 2
|
||||
assert sleep_calls == [0.25]
|
||||
@@ -358,4 +207,4 @@ def test_cdek_client_register_order_raises_client_error_for_invalid_success_payl
|
||||
)
|
||||
|
||||
with pytest.raises(CDEKClientError, match="response payload is invalid"):
|
||||
asyncio.run(client.register_order(_make_order_request()))
|
||||
asyncio.run(client.register_order(make_init_payment_request()))
|
||||
|
||||
@@ -1,53 +1,209 @@
|
||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
CDEKOrderRegistrationResult,
|
||||
compose_address_line,
|
||||
map_cdek_existing_order_response,
|
||||
map_cdek_order_request,
|
||||
map_cdek_order_response,
|
||||
)
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
from app.schemas.payment import Address
|
||||
from tests.payment_fixtures import make_init_payment_request
|
||||
|
||||
|
||||
def _make_order_request(**overrides: object) -> InitPaymentRequest:
|
||||
payload: dict[str, object] = {
|
||||
"order_uuid": "order-uuid-1",
|
||||
"price": 125000,
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Test order",
|
||||
"sender": {
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": {"number": "+79009876543"},
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {"number": "+79001234567"},
|
||||
},
|
||||
"from_location": {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"to_location": {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||
"packages": [
|
||||
def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
|
||||
assert payload["number"] == "order-uuid-1"
|
||||
assert payload["type"] == 2
|
||||
assert payload["tariff_code"] == 535
|
||||
|
||||
|
||||
def test_order_payload_requests_waybill_print() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
|
||||
assert payload["print"] == "WAYBILL"
|
||||
|
||||
|
||||
def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
|
||||
result = map_cdek_order_response({"entity": {"uuid": "cdek-order-uuid"}})
|
||||
|
||||
assert result == CDEKOrderRegistrationResult(
|
||||
order_uuid="cdek-order-uuid",
|
||||
waybill_uuid=None,
|
||||
waybill_url=None,
|
||||
)
|
||||
|
||||
|
||||
def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> None:
|
||||
result = map_cdek_order_response(
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
"entity": {"uuid": "cdek-order-uuid"},
|
||||
"related_entities": [
|
||||
{"type": "delivery", "uuid": "ignored"},
|
||||
{
|
||||
"type": "waybill",
|
||||
"uuid": "waybill-uuid-1",
|
||||
"url": "https://cdek.test/waybill/1.pdf",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert result == CDEKOrderRegistrationResult(
|
||||
order_uuid="cdek-order-uuid",
|
||||
waybill_uuid="waybill-uuid-1",
|
||||
waybill_url="https://cdek.test/waybill/1.pdf",
|
||||
)
|
||||
|
||||
|
||||
def test_map_cdek_existing_order_response_returns_waybill_for_duplicate() -> None:
|
||||
result = map_cdek_existing_order_response(
|
||||
{
|
||||
"entity": {"uuid": "existing-uuid"},
|
||||
"requests": [
|
||||
{"errors": [{"code": "v2_entity_already_exists", "message": "dup"}]}
|
||||
],
|
||||
"related_entities": [
|
||||
{
|
||||
"type": "waybill",
|
||||
"uuid": "waybill-existing",
|
||||
"url": "https://cdek.test/waybill/existing.pdf",
|
||||
}
|
||||
],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return InitPaymentRequest(**payload)
|
||||
)
|
||||
|
||||
assert result == CDEKOrderRegistrationResult(
|
||||
order_uuid="existing-uuid",
|
||||
waybill_uuid="waybill-existing",
|
||||
waybill_url="https://cdek.test/waybill/existing.pdf",
|
||||
)
|
||||
|
||||
|
||||
def test_cdek_order_payload_uses_order_uuid_as_external_number() -> None:
|
||||
payload = map_cdek_order_request(_make_order_request())
|
||||
def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
|
||||
assert payload["number"] == "order-uuid-1"
|
||||
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
|
||||
assert payload["recipient"]["phones"] == [
|
||||
{"number": "+79001234567", "additional": "101"}
|
||||
]
|
||||
|
||||
|
||||
def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
|
||||
assert payload["from_location"] == {
|
||||
"code": 7017,
|
||||
"address": "Дубай, Sheikh Zayed Road, 10, кв. 201",
|
||||
"postal_code": "12345",
|
||||
}
|
||||
assert payload["to_location"] == {
|
||||
"code": 16454,
|
||||
"address": "Шарджа, Al Wahda, 5",
|
||||
"postal_code": "54321",
|
||||
}
|
||||
|
||||
|
||||
def test_order_payload_for_parcel_includes_dimensions() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
|
||||
package = payload["packages"][0]
|
||||
assert package["weight"] == 1000
|
||||
assert package["length"] == 20
|
||||
assert package["width"] == 15
|
||||
assert package["height"] == 10
|
||||
|
||||
|
||||
def test_order_payload_for_doc_omits_dimensions() -> None:
|
||||
request = make_init_payment_request(
|
||||
content={"description": "Docs"},
|
||||
systemData={
|
||||
"tariff": {
|
||||
"provider": "СДЭК",
|
||||
"serviceName": "Документы",
|
||||
"price": 50000,
|
||||
"deliveryDaysMin": 1,
|
||||
"deliveryDaysMax": 2,
|
||||
"tariffCode": 535,
|
||||
},
|
||||
"parcelType": "doc",
|
||||
"docPackaging": "envelope",
|
||||
"weight": "0.5",
|
||||
"dimensions": None,
|
||||
},
|
||||
)
|
||||
|
||||
payload = map_cdek_order_request(request)
|
||||
package = payload["packages"][0]
|
||||
|
||||
assert package["weight"] == 500
|
||||
assert "length" not in package
|
||||
assert "width" not in package
|
||||
assert "height" not in package
|
||||
|
||||
|
||||
def test_order_payload_omits_shipment_and_delivery_point() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
|
||||
assert "shipment_point" not in payload
|
||||
assert "delivery_point" not in payload
|
||||
|
||||
|
||||
def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
|
||||
request = make_init_payment_request(
|
||||
senderContact={
|
||||
"fullName": "Romashka LLC",
|
||||
"email": "buh@romashka.test",
|
||||
"phone": "+74950000000",
|
||||
"phoneExt": None,
|
||||
"isCompany": True,
|
||||
"companyName": "Romashka LLC",
|
||||
"inn": "7707083893",
|
||||
"kpp": "770701001",
|
||||
}
|
||||
)
|
||||
|
||||
sender = map_cdek_order_request(request)["sender"]
|
||||
|
||||
assert sender["contragent_type"] == "LEGAL_ENTITY"
|
||||
assert sender["company"] == "Romashka LLC"
|
||||
assert sender["inn"] == "7707083893"
|
||||
assert sender["kpp"] == "770701001"
|
||||
|
||||
|
||||
def test_order_payload_propagates_description_to_comments_without_items() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
|
||||
assert payload["comment"] == "Headphones"
|
||||
assert payload["packages"][0]["comment"] == "Headphones"
|
||||
assert "items" not in payload["packages"][0]
|
||||
|
||||
|
||||
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
|
||||
payload = map_cdek_order_request(
|
||||
make_init_payment_request(content={"description": None})
|
||||
)
|
||||
|
||||
assert payload["packages"][0]["comment"] == "order-uuid-1"
|
||||
|
||||
|
||||
def test_order_payload_includes_company_for_individual_sender_as_full_name() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request())
|
||||
|
||||
assert payload["sender"]["company"] == "Petr Petrov"
|
||||
assert payload["recipient"]["company"] == "Ivan Ivanov"
|
||||
assert "contragent_type" not in payload["sender"]
|
||||
assert "contragent_type" not in payload["recipient"]
|
||||
|
||||
|
||||
def test_compose_address_line_handles_empty_apartment() -> None:
|
||||
address = Address(
|
||||
cityId=1,
|
||||
city="Москва",
|
||||
street="Lenina",
|
||||
house="1",
|
||||
apartment=None,
|
||||
zip="12345",
|
||||
comment=None,
|
||||
)
|
||||
|
||||
assert compose_address_line(address) == "Москва, Lenina, 1"
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.adapters.delivery_providers.cdek.client import (
|
||||
CDEKProvider,
|
||||
CDEKRequestError,
|
||||
)
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
from tests.payment_fixtures import make_init_payment_request
|
||||
|
||||
|
||||
class StubAuthClient:
|
||||
@@ -52,62 +52,11 @@ class SequenceHTTPClient:
|
||||
return result
|
||||
|
||||
|
||||
def _make_request(**overrides: object) -> InitPaymentRequest:
|
||||
payload: dict[str, object] = {
|
||||
"order_uuid": "order-uuid-1",
|
||||
"price": 125000,
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Test payment",
|
||||
"sender": {
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": {"number": "+79009876543"},
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {"number": "+79001234567"},
|
||||
},
|
||||
"from_location": {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"to_location": {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||
"packages": [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
}
|
||||
],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return InitPaymentRequest(**payload)
|
||||
|
||||
|
||||
def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_tariff() -> None:
|
||||
def test_provider_get_payment_price_posts_payload_from_camelcase_request() -> None:
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"tariff_codes": [
|
||||
{
|
||||
"tariff_code": 234,
|
||||
"tariff_name": "Other tariff",
|
||||
"delivery_sum": "999.00",
|
||||
"currency": "RUB",
|
||||
"period_min": 3,
|
||||
"period_max": 5,
|
||||
},
|
||||
{
|
||||
"tariff_code": 535,
|
||||
"tariff_name": "CDEK tariff",
|
||||
@@ -131,11 +80,9 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(provider.get_payment_price(_make_request()))
|
||||
result = asyncio.run(provider.get_payment_price(make_init_payment_request()))
|
||||
|
||||
assert result is not None
|
||||
assert result.provider == "cdek"
|
||||
assert result.service_name == "CDEK tariff"
|
||||
assert result.price == Decimal("1250.00")
|
||||
assert http_client.calls == [
|
||||
{
|
||||
@@ -143,16 +90,8 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
|
||||
"url": "https://api.cdek.test/v2/calculator/tarifflist",
|
||||
"json": {
|
||||
"type": 2,
|
||||
"from_location": {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"to_location": {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"from_location": {"code": 7017},
|
||||
"to_location": {"code": 16454},
|
||||
"packages": [
|
||||
{
|
||||
"weight": 1000,
|
||||
@@ -161,7 +100,6 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
|
||||
"height": 10,
|
||||
}
|
||||
],
|
||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||
},
|
||||
"data": None,
|
||||
"headers": {"Authorization": "Bearer test-token"},
|
||||
@@ -170,7 +108,7 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
|
||||
]
|
||||
|
||||
|
||||
def test_provider_get_payment_price_omits_services_when_none() -> None:
|
||||
def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={"tariff_codes": []},
|
||||
@@ -185,11 +123,28 @@ def test_provider_get_payment_price_omits_services_when_none() -> None:
|
||||
retry_attempts=0,
|
||||
)
|
||||
)
|
||||
request = make_init_payment_request(
|
||||
content={"description": "Docs"},
|
||||
systemData={
|
||||
"tariff": {
|
||||
"provider": "СДЭК",
|
||||
"serviceName": "Документы",
|
||||
"price": 50000,
|
||||
"deliveryDaysMin": 1,
|
||||
"deliveryDaysMax": 2,
|
||||
"tariffCode": 535,
|
||||
},
|
||||
"parcelType": "doc",
|
||||
"docPackaging": "envelope",
|
||||
"weight": "0.5",
|
||||
"dimensions": None,
|
||||
},
|
||||
)
|
||||
|
||||
result = asyncio.run(provider.get_payment_price(_make_request(services=None)))
|
||||
asyncio.run(provider.get_payment_price(request))
|
||||
|
||||
assert result is None
|
||||
assert "services" not in http_client.calls[0]["json"]
|
||||
package = http_client.calls[0]["json"]["packages"][0]
|
||||
assert package == {"weight": 500}
|
||||
|
||||
|
||||
def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
|
||||
@@ -207,7 +162,7 @@ def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
|
||||
)
|
||||
|
||||
with pytest.raises(CDEKRequestError, match="status 422"):
|
||||
asyncio.run(client.get_raw_payment_price(_make_request()))
|
||||
asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
|
||||
|
||||
assert len(http_client.calls) == 1
|
||||
|
||||
@@ -239,7 +194,7 @@ def test_client_get_raw_payment_price_retries_5xx_and_raises_client_error() -> N
|
||||
)
|
||||
|
||||
with pytest.raises(CDEKClientError, match="status 503"):
|
||||
asyncio.run(client.get_raw_payment_price(_make_request()))
|
||||
asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
|
||||
|
||||
assert len(http_client.calls) == 2
|
||||
assert sleep_calls == [0.25]
|
||||
@@ -262,4 +217,4 @@ def test_provider_get_payment_price_maps_invalid_success_payload_to_client_error
|
||||
)
|
||||
|
||||
with pytest.raises(CDEKClientError, match="response payload is invalid"):
|
||||
asyncio.run(provider.get_payment_price(_make_request()))
|
||||
asyncio.run(provider.get_payment_price(make_init_payment_request()))
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.services.aggregator import (
|
||||
InitPaymentUnavailableError,
|
||||
InvalidInitPaymentRequestError,
|
||||
)
|
||||
from tests.payment_fixtures import make_init_payment_payload
|
||||
|
||||
|
||||
class StubAggregatorService:
|
||||
@@ -31,45 +32,16 @@ def _install_service_override(app, service: StubAggregatorService) -> None:
|
||||
app.dependency_overrides[get_aggregator_service] = override_service
|
||||
|
||||
|
||||
def _valid_payload() -> dict[str, object]:
|
||||
return {
|
||||
"order_uuid": "order-uuid-1",
|
||||
"price": 125000,
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Test payment",
|
||||
"sender": {
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": {"number": "+79009876543"},
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {"number": "+79001234567"},
|
||||
},
|
||||
"from_location": {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"to_location": {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||
"packages": [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
}
|
||||
],
|
||||
}
|
||||
def _post(app, payload: dict[str, object]) -> httpx.Response:
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post("/api/v1/delivery/order", json=payload)
|
||||
|
||||
return asyncio.run(run_request())
|
||||
|
||||
|
||||
def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
|
||||
@@ -77,44 +49,64 @@ def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
|
||||
service = StubAggregatorService(response=expected_response)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
payload = make_init_payment_payload()
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=_valid_payload(),
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
response = _post(app, payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == expected_response.model_dump(mode="json")
|
||||
assert service.calls == [InitPaymentRequest(**_valid_payload())]
|
||||
assert service.calls == [InitPaymentRequest.model_validate(payload)]
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_invalid_payload() -> None:
|
||||
def test_post_init_payment_rejects_invalid_tariff_code() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = _valid_payload()
|
||||
invalid_payload["tariff_code"] = "not-a-number"
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["systemData"]["tariff"]["tariffCode"] = "not-a-number"
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_snake_case_top_level_field() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["order_uuid"] = invalid_payload.pop("orderUuid")
|
||||
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_snake_case_nested_address_field() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = make_init_payment_payload()
|
||||
sender_address = invalid_payload["senderAddress"]
|
||||
sender_address["city_id"] = sender_address.pop("cityId")
|
||||
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_snake_case_nested_tariff_field() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = make_init_payment_payload()
|
||||
tariff = invalid_payload["systemData"]["tariff"]
|
||||
tariff["tariff_code"] = tariff.pop("tariffCode")
|
||||
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
@@ -124,21 +116,10 @@ def test_post_init_payment_rejects_missing_price() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = _valid_payload()
|
||||
del invalid_payload["price"]
|
||||
invalid_payload = make_init_payment_payload()
|
||||
del invalid_payload["systemData"]["tariff"]["price"]
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
@@ -148,21 +129,10 @@ def test_post_init_payment_rejects_non_positive_price() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = _valid_payload()
|
||||
invalid_payload["price"] = 0
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["systemData"]["tariff"]["price"] = 0
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
@@ -172,155 +142,102 @@ def test_post_init_payment_rejects_non_integer_price() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = _valid_payload()
|
||||
invalid_payload["price"] = 125000.5
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["systemData"]["tariff"]["price"] = 125000.5
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_sender_company_field() -> None:
|
||||
def test_post_init_payment_requires_company_fields_when_is_company_true() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = _valid_payload()
|
||||
invalid_payload["sender"] = {
|
||||
**invalid_payload["sender"], # type: ignore[arg-type]
|
||||
"company": "Romashka LLC",
|
||||
}
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["senderContact"]["isCompany"] = True
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_sender_phones_field() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = _valid_payload()
|
||||
invalid_payload["sender"] = {
|
||||
**invalid_payload["sender"], # type: ignore[arg-type]
|
||||
"phones": [{"number": "+79009876543"}],
|
||||
}
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_recipient_company_field() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = _valid_payload()
|
||||
invalid_payload["recipient"] = {
|
||||
**invalid_payload["recipient"], # type: ignore[arg-type]
|
||||
"company": "Romashka LLC",
|
||||
}
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_recipient_phones_field() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = _valid_payload()
|
||||
invalid_payload["recipient"] = {
|
||||
**invalid_payload["recipient"], # type: ignore[arg-type]
|
||||
"phones": [{"number": "+79001234567"}],
|
||||
}
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=invalid_payload,
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_accepts_request_without_services() -> None:
|
||||
def test_post_init_payment_accepts_company_with_full_requisites() -> None:
|
||||
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||
service = StubAggregatorService(response=expected_response)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
payload = _valid_payload()
|
||||
del payload["services"]
|
||||
payload = make_init_payment_payload()
|
||||
payload["senderContact"].update(
|
||||
{
|
||||
"isCompany": True,
|
||||
"companyName": "Romashka LLC",
|
||||
"inn": "7707083893",
|
||||
"kpp": "770701001",
|
||||
}
|
||||
)
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post("/api/v1/delivery/order", json=payload)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
response = _post(app, payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == expected_response.model_dump(mode="json")
|
||||
assert service.calls == [InitPaymentRequest(**payload)]
|
||||
assert service.calls == [InitPaymentRequest.model_validate(payload)]
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_non_numeric_weight() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["systemData"]["weight"] = "string"
|
||||
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_non_numeric_dimension() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["systemData"]["dimensions"]["length"] = "string"
|
||||
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_parcel_without_dimensions() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["systemData"]["dimensions"] = None
|
||||
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_accepts_doc_without_dimensions() -> None:
|
||||
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||
service = StubAggregatorService(response=expected_response)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
payload = make_init_payment_payload()
|
||||
payload["systemData"]["parcelType"] = "doc"
|
||||
payload["systemData"]["docPackaging"] = "envelope"
|
||||
payload["systemData"]["dimensions"] = None
|
||||
|
||||
response = _post(app, payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert service.calls == [InitPaymentRequest.model_validate(payload)]
|
||||
|
||||
|
||||
def test_post_init_payment_maps_invalid_request_to_400() -> None:
|
||||
@@ -331,18 +248,7 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=_valid_payload(),
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
response = _post(app, make_init_payment_payload())
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
@@ -353,37 +259,6 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_post_init_payment_maps_price_mismatch_to_400() -> None:
|
||||
service = StubAggregatorService(
|
||||
response=None,
|
||||
error=InvalidInitPaymentRequestError("price mismatch"),
|
||||
)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=_valid_payload(),
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"detail": {
|
||||
"code": "invalid_init_payment_request",
|
||||
"message": "Payment request contains invalid or unsupported TBank data.",
|
||||
}
|
||||
}
|
||||
assert service.calls == [InitPaymentRequest(**_valid_payload())]
|
||||
|
||||
|
||||
def test_post_init_payment_maps_service_exception_to_503() -> None:
|
||||
service = StubAggregatorService(
|
||||
response=None,
|
||||
@@ -392,18 +267,7 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=_valid_payload(),
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
response = _post(app, make_init_payment_payload())
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
@@ -412,53 +276,3 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
|
||||
"message": "Payment initialization is temporarily unavailable.",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_post_init_payment_maps_temporary_cdek_validation_error_to_503() -> None:
|
||||
service = StubAggregatorService(
|
||||
response=None,
|
||||
error=InitPaymentUnavailableError("CDEK validation unavailable"),
|
||||
)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post(
|
||||
"/api/v1/delivery/order",
|
||||
json=_valid_payload(),
|
||||
)
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {
|
||||
"detail": {
|
||||
"code": "init_payment_unavailable",
|
||||
"message": "Payment initialization is temporarily unavailable.",
|
||||
}
|
||||
}
|
||||
assert service.calls == [InitPaymentRequest(**_valid_payload())]
|
||||
|
||||
|
||||
def test_delivery_order_endpoint_is_removed() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
async def run_request() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
return await client.post("/api/v1/delivery/order", json=_valid_payload())
|
||||
|
||||
response = asyncio.run(run_request())
|
||||
|
||||
assert response.status_code == 404
|
||||
assert service.calls == []
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Shared fixtures for the camelCase init-payment contract."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
|
||||
|
||||
def make_init_payment_payload(**overrides: Any) -> dict[str, Any]:
|
||||
"""Return a valid camelCase JSON payload for /api/v1/delivery/order."""
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"orderUuid": "order-uuid-1",
|
||||
"senderAddress": {
|
||||
"cityId": 1,
|
||||
"city": "Дубай",
|
||||
"street": "Sheikh Zayed Road",
|
||||
"house": "10",
|
||||
"apartment": "201",
|
||||
"zip": "12345",
|
||||
"comment": "Domofon 12",
|
||||
},
|
||||
"senderContact": {
|
||||
"fullName": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": "+79009876543",
|
||||
"phoneExt": None,
|
||||
"isCompany": False,
|
||||
"companyName": None,
|
||||
"inn": None,
|
||||
"kpp": None,
|
||||
},
|
||||
"receiverAddress": {
|
||||
"cityId": 2,
|
||||
"city": "Шарджа",
|
||||
"street": "Al Wahda",
|
||||
"house": "5",
|
||||
"apartment": None,
|
||||
"zip": "54321",
|
||||
"comment": None,
|
||||
},
|
||||
"receiverContact": {
|
||||
"fullName": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": "+79001234567",
|
||||
"phoneExt": "101",
|
||||
"isCompany": False,
|
||||
"companyName": None,
|
||||
"inn": None,
|
||||
"kpp": None,
|
||||
},
|
||||
"content": {
|
||||
"description": "Headphones",
|
||||
},
|
||||
"pickupDate": "2026-05-15T10:00:00.000Z",
|
||||
"deliveryDate": "2026-05-18T18:00:00.000Z",
|
||||
"accountEmail": "client@example.com",
|
||||
"systemData": {
|
||||
"tariff": {
|
||||
"provider": "СДЭК",
|
||||
"serviceName": "Экспресс лайт",
|
||||
"price": 125000,
|
||||
"deliveryDaysMin": 1,
|
||||
"deliveryDaysMax": 2,
|
||||
"tariffCode": 535,
|
||||
},
|
||||
"parcelType": "parcel",
|
||||
"docPackaging": None,
|
||||
"weight": "1.0",
|
||||
"dimensions": {
|
||||
"length": "20",
|
||||
"width": "15",
|
||||
"height": "10",
|
||||
},
|
||||
},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def make_init_payment_request(**overrides: Any) -> InitPaymentRequest:
|
||||
return InitPaymentRequest.model_validate(make_init_payment_payload(**overrides))
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.repositories.order import OrderData, OrderRepository
|
||||
from app.repositories.order.models import Base, Order
|
||||
from tests.payment_fixtures import make_init_payment_payload
|
||||
|
||||
|
||||
def _make_order_data(**overrides: object) -> OrderData:
|
||||
@@ -17,40 +18,9 @@ def _make_order_data(**overrides: object) -> OrderData:
|
||||
"order_uuid": "order-uuid-1",
|
||||
"payment_url": "https://pay.test/payment/1",
|
||||
"price": 125000,
|
||||
"delivery_type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Test payment",
|
||||
"sender": {
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": {"number": "+79009876543"},
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {"number": "+79001234567"},
|
||||
},
|
||||
"from_location": {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"to_location": {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||
"packages": [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
}
|
||||
],
|
||||
"account_email": "client@example.com",
|
||||
"payload": make_init_payment_payload(),
|
||||
}
|
||||
payload.update(overrides)
|
||||
return OrderData(**payload)
|
||||
@@ -92,46 +62,20 @@ def test_create_order_persists_all_required_fields() -> None:
|
||||
assert persisted_order.order_uuid == "order-uuid-1"
|
||||
assert persisted_order.payment_url == "https://pay.test/payment/1"
|
||||
assert persisted_order.price == 125000
|
||||
assert persisted_order.delivery_type == 2
|
||||
assert persisted_order.tariff_code == 535
|
||||
assert persisted_order.sender == order_data.sender
|
||||
assert persisted_order.recipient == order_data.recipient
|
||||
assert persisted_order.from_location == order_data.from_location
|
||||
assert persisted_order.to_location == order_data.to_location
|
||||
assert persisted_order.packages == order_data.packages
|
||||
assert persisted_order.services == order_data.services
|
||||
assert persisted_order.comment == "Test payment"
|
||||
assert persisted_order.account_email == "client@example.com"
|
||||
assert persisted_order.payload == order_data.payload
|
||||
assert persisted_order.payment_status is None
|
||||
assert persisted_order.tbank_payment_id is None
|
||||
assert persisted_order.cdek_order_uuid is None
|
||||
assert persisted_order.cdek_waybill_uuid is None
|
||||
assert persisted_order.cdek_waybill_url is None
|
||||
assert persisted_order.created_at is not None
|
||||
assert persisted_order.updated_at is not None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_create_order_persists_nullable_services_and_comment() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
order_data = _make_order_data(services=None, comment=None)
|
||||
|
||||
async with repository.session() as session:
|
||||
await repository.create_order(session, order_data)
|
||||
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(Order).where(Order.order_uuid == "order-uuid-1")
|
||||
)
|
||||
persisted_order = result.scalar_one()
|
||||
|
||||
assert persisted_order.services is None
|
||||
assert persisted_order.comment is None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_create_order_rejects_duplicate_order_uuid() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
@@ -229,7 +173,7 @@ def test_mark_payment_status_returns_none_for_missing_order() -> None:
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
|
||||
def test_mark_cdek_order_registered_persists_cdek_order_and_waybill() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
@@ -242,6 +186,8 @@ def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
|
||||
session,
|
||||
"order-uuid-1",
|
||||
"cdek-order-uuid-1",
|
||||
"waybill-uuid-1",
|
||||
"https://cdek.test/waybill/1.pdf",
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
@@ -252,6 +198,36 @@ def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
|
||||
|
||||
assert order is not None
|
||||
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
|
||||
assert persisted_order.cdek_waybill_uuid == "waybill-uuid-1"
|
||||
assert persisted_order.cdek_waybill_url == "https://cdek.test/waybill/1.pdf"
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_cdek_order_registered_allows_missing_waybill_fields() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
async with repository.session() as session:
|
||||
await repository.create_order(session, _make_order_data())
|
||||
|
||||
async with repository.session() as session:
|
||||
await repository.mark_cdek_order_registered(
|
||||
session,
|
||||
"order-uuid-1",
|
||||
"cdek-order-uuid-1",
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(Order).where(Order.order_uuid == "order-uuid-1")
|
||||
)
|
||||
persisted_order = result.scalar_one()
|
||||
|
||||
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
|
||||
assert persisted_order.cdek_waybill_uuid is None
|
||||
assert persisted_order.cdek_waybill_url is None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.services.aggregator import (
|
||||
InitPaymentUnavailableError,
|
||||
InvalidInitPaymentRequestError,
|
||||
)
|
||||
from tests.payment_fixtures import make_init_payment_payload, make_init_payment_request
|
||||
|
||||
|
||||
class StubPaymentAdapter:
|
||||
@@ -94,49 +95,6 @@ class StubOrderRepository:
|
||||
return object()
|
||||
|
||||
|
||||
def _make_init_payment_request(**overrides: object) -> InitPaymentRequest:
|
||||
payload: dict[str, object] = {
|
||||
"order_uuid": "order-uuid-1",
|
||||
"price": 125000,
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Test payment",
|
||||
"sender": {
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": {"number": "+79009876543"},
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {"number": "+79001234567"},
|
||||
},
|
||||
"from_location": {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"to_location": {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||
"packages": [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
}
|
||||
],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return InitPaymentRequest(**payload)
|
||||
|
||||
|
||||
def _make_cdek_price(**overrides: object) -> DeliveryPrice:
|
||||
payload = {
|
||||
"provider": "cdek",
|
||||
@@ -151,7 +109,7 @@ def _make_cdek_price(**overrides: object) -> DeliveryPrice:
|
||||
|
||||
|
||||
def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None:
|
||||
request = _make_init_payment_request()
|
||||
request = make_init_payment_request()
|
||||
events: list[str] = []
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||
response=_make_cdek_price(),
|
||||
@@ -172,8 +130,9 @@ def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url(
|
||||
assert events == ["cdek", "tbank"]
|
||||
|
||||
|
||||
def test_init_payment_persists_order_after_successful_payment_link() -> None:
|
||||
request = _make_init_payment_request()
|
||||
def test_init_payment_persists_order_payload_after_successful_payment_link() -> None:
|
||||
payload = make_init_payment_payload()
|
||||
request = InitPaymentRequest.model_validate(payload)
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
order_repository = StubOrderRepository()
|
||||
@@ -184,59 +143,21 @@ def test_init_payment_persists_order_after_successful_payment_link() -> None:
|
||||
order_repository=order_repository,
|
||||
)
|
||||
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
assert order_repository.calls == [
|
||||
(
|
||||
order_repository.session_value,
|
||||
OrderData(
|
||||
order_uuid="order-uuid-1",
|
||||
payment_url="https://pay.test/payment/1",
|
||||
price=125000,
|
||||
delivery_type=2,
|
||||
tariff_code=535,
|
||||
sender={
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": {"number": "+79009876543"},
|
||||
},
|
||||
recipient={
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {"number": "+79001234567"},
|
||||
},
|
||||
from_location={
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
},
|
||||
to_location={
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
},
|
||||
packages=[
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
}
|
||||
],
|
||||
services=[{"code": "INSURANCE", "parameter": "1000"}],
|
||||
comment="Test payment",
|
||||
),
|
||||
)
|
||||
]
|
||||
assert len(order_repository.calls) == 1
|
||||
session, order_data = order_repository.calls[0]
|
||||
assert session is order_repository.session_value
|
||||
assert order_data.order_uuid == "order-uuid-1"
|
||||
assert order_data.payment_url == "https://pay.test/payment/1"
|
||||
assert order_data.price == 125000
|
||||
assert order_data.tariff_code == 535
|
||||
assert order_data.account_email == "client@example.com"
|
||||
assert order_data.payload == request.model_dump(mode="json", by_alias=True)
|
||||
|
||||
|
||||
def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None:
|
||||
request = _make_init_payment_request()
|
||||
request = make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
order_repository = StubOrderRepository(error=RuntimeError("database down"))
|
||||
@@ -250,13 +171,12 @@ def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
|
||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
assert len(order_repository.calls) == 1
|
||||
|
||||
|
||||
def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None:
|
||||
request = _make_init_payment_request()
|
||||
request = make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
service = AggregatorService(
|
||||
@@ -268,30 +188,9 @@ def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() ->
|
||||
with pytest.raises(InvalidInitPaymentRequestError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
|
||||
|
||||
def test_init_payment_delegates_request_with_services_none_without_new_logic() -> None:
|
||||
request = _make_init_payment_request(services=None)
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/2")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=adapter,
|
||||
payment_price_validation_adapter=validation_adapter,
|
||||
)
|
||||
|
||||
result = asyncio.run(service.init_payment(request))
|
||||
|
||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2")
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
assert request.services is None
|
||||
|
||||
|
||||
def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
|
||||
request = _make_init_payment_request()
|
||||
request = make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down"))
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
service = AggregatorService(
|
||||
@@ -303,19 +202,18 @@ def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
|
||||
with pytest.raises(InitPaymentUnavailableError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||
|
||||
|
||||
def test_init_payment_without_configured_adapter_raises_unavailable_error() -> None:
|
||||
service = AggregatorService(providers=[])
|
||||
|
||||
with pytest.raises(InitPaymentUnavailableError):
|
||||
asyncio.run(service.init_payment(_make_init_payment_request()))
|
||||
asyncio.run(service.init_payment(make_init_payment_request()))
|
||||
|
||||
|
||||
def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_calls() -> None:
|
||||
request = _make_init_payment_request(price=124999)
|
||||
payload = make_init_payment_payload()
|
||||
payload["systemData"]["tariff"]["price"] = 124999
|
||||
request = InitPaymentRequest.model_validate(payload)
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||
order_repository = StubOrderRepository()
|
||||
@@ -329,13 +227,12 @@ def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_ca
|
||||
with pytest.raises(InvalidInitPaymentRequestError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == []
|
||||
assert order_repository.calls == []
|
||||
|
||||
|
||||
def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> None:
|
||||
request = _make_init_payment_request()
|
||||
request = make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(response=None)
|
||||
service = AggregatorService(
|
||||
@@ -347,12 +244,11 @@ def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> No
|
||||
with pytest.raises(InvalidInitPaymentRequestError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == []
|
||||
|
||||
|
||||
def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None:
|
||||
request = _make_init_payment_request()
|
||||
request = make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||
error=ProviderRequestError("bad CDEK payload")
|
||||
@@ -366,12 +262,9 @@ def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None
|
||||
with pytest.raises(InvalidInitPaymentRequestError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == []
|
||||
|
||||
|
||||
def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
|
||||
request = _make_init_payment_request()
|
||||
request = make_init_payment_request()
|
||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||
error=ProviderClientError("CDEK unavailable")
|
||||
@@ -384,6 +277,3 @@ def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
|
||||
|
||||
with pytest.raises(InitPaymentUnavailableError):
|
||||
asyncio.run(service.init_payment(request))
|
||||
|
||||
assert validation_adapter.calls == [request]
|
||||
assert adapter.calls == []
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
CDEKOrderRegistrationResult,
|
||||
)
|
||||
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
|
||||
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
|
||||
from app.services.aggregator import (
|
||||
@@ -11,6 +14,11 @@ from app.services.aggregator import (
|
||||
InvalidTBankPaymentNotificationError,
|
||||
TBankPaymentNotificationProcessingError,
|
||||
)
|
||||
from tests.payment_fixtures import make_init_payment_payload
|
||||
|
||||
|
||||
def _default_payload() -> dict[str, Any]:
|
||||
return make_init_payment_payload()
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -18,57 +26,14 @@ class StoredOrder:
|
||||
order_uuid: str = "order-uuid-1"
|
||||
payment_url: str = "https://pay.test/payment/1"
|
||||
price: int = 125000
|
||||
delivery_type: int = 2
|
||||
tariff_code: int = 535
|
||||
comment: str | None = "Test payment"
|
||||
sender: dict[str, Any] | None = None
|
||||
recipient: dict[str, Any] | None = None
|
||||
from_location: dict[str, Any] | None = None
|
||||
to_location: dict[str, Any] | None = None
|
||||
packages: list[dict[str, Any]] | None = None
|
||||
services: list[dict[str, Any]] | None = None
|
||||
account_email: str = "client@example.com"
|
||||
payload: dict[str, Any] = field(default_factory=_default_payload)
|
||||
payment_status: str | None = None
|
||||
tbank_payment_id: int | None = None
|
||||
cdek_order_uuid: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.sender is None:
|
||||
self.sender = {
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": {"number": "+79009876543"},
|
||||
}
|
||||
if self.recipient is None:
|
||||
self.recipient = {
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {"number": "+79001234567"},
|
||||
}
|
||||
if self.from_location is None:
|
||||
self.from_location = {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
}
|
||||
if self.to_location is None:
|
||||
self.to_location = {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
}
|
||||
if self.packages is None:
|
||||
self.packages = [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
}
|
||||
]
|
||||
if self.services is None:
|
||||
self.services = [{"code": "INSURANCE", "parameter": "1000"}]
|
||||
cdek_waybill_uuid: str | None = None
|
||||
cdek_waybill_url: str | None = None
|
||||
|
||||
|
||||
class StubPaymentAdapter:
|
||||
@@ -148,9 +113,20 @@ 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))
|
||||
(
|
||||
"mark_cdek_order_registered",
|
||||
(
|
||||
session,
|
||||
order_uuid,
|
||||
cdek_order_uuid,
|
||||
cdek_waybill_uuid,
|
||||
cdek_waybill_url,
|
||||
),
|
||||
)
|
||||
)
|
||||
if self._mark_cdek_errors:
|
||||
error = self._mark_cdek_errors.pop(0)
|
||||
@@ -161,6 +137,8 @@ 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
|
||||
|
||||
|
||||
@@ -168,14 +146,22 @@ class StubCDEKOrderAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
responses: list[str] | None = None,
|
||||
responses: list[CDEKOrderRegistrationResult] | None = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self._responses = responses or ["cdek-order-uuid-1"]
|
||||
self._responses = responses or [
|
||||
CDEKOrderRegistrationResult(
|
||||
order_uuid="cdek-order-uuid-1",
|
||||
waybill_uuid=None,
|
||||
waybill_url=None,
|
||||
)
|
||||
]
|
||||
self._error = error
|
||||
self.calls: list[InitPaymentRequest] = []
|
||||
|
||||
async def register_order(self, request: InitPaymentRequest) -> str:
|
||||
async def register_order(
|
||||
self, request: InitPaymentRequest
|
||||
) -> CDEKOrderRegistrationResult:
|
||||
self.calls.append(request)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
@@ -201,7 +187,15 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
|
||||
order = StoredOrder()
|
||||
payment_adapter = StubPaymentAdapter()
|
||||
order_repository = StubOrderRepository(orders=[order])
|
||||
cdek_adapter = StubCDEKOrderAdapter(responses=["cdek-order-uuid-1"])
|
||||
cdek_adapter = StubCDEKOrderAdapter(
|
||||
responses=[
|
||||
CDEKOrderRegistrationResult(
|
||||
order_uuid="cdek-order-uuid-1",
|
||||
waybill_uuid="waybill-uuid-1",
|
||||
waybill_url="https://cdek.test/waybill/1.pdf",
|
||||
)
|
||||
]
|
||||
)
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=payment_adapter,
|
||||
@@ -217,6 +211,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 len(cdek_adapter.calls) == 1
|
||||
assert cdek_adapter.calls[0].order_uuid == "order-uuid-1"
|
||||
|
||||
@@ -317,7 +313,18 @@ def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id()
|
||||
mark_cdek_errors=[RuntimeError("db down"), None],
|
||||
)
|
||||
cdek_adapter = StubCDEKOrderAdapter(
|
||||
responses=["same-cdek-order-uuid", "same-cdek-order-uuid"]
|
||||
responses=[
|
||||
CDEKOrderRegistrationResult(
|
||||
order_uuid="same-cdek-order-uuid",
|
||||
waybill_uuid=None,
|
||||
waybill_url=None,
|
||||
),
|
||||
CDEKOrderRegistrationResult(
|
||||
order_uuid="same-cdek-order-uuid",
|
||||
waybill_uuid=None,
|
||||
waybill_url=None,
|
||||
),
|
||||
]
|
||||
)
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
|
||||
Reference in New Issue
Block a user