Добавлен заказ накладной

This commit is contained in:
Раис Юсупалиев
2026-05-23 17:29:56 +03:00
parent 39cd5ddc7a
commit c494d50566
21 changed files with 1384 additions and 1126 deletions
@@ -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 == [
{
"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",
}
],
},
"data": None,
"headers": {"Authorization": "Bearer test-token"},
"timeout": 7.5,
}
]
def test_provider_register_order_omits_services_when_none_and_converts_package_weights() -> None:
def test_provider_register_order_posts_payload_and_maps_response() -> None:
response = httpx.Response(
200,
json={"entity": {"uuid": "cdek-order-uuid"}},
json={
"entity": {"uuid": "cdek-order-uuid"},
"related_entities": [
{
"type": "waybill",
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
}
],
},
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": [
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
}
payload.update(overrides)
return InitPaymentRequest(**payload)
def test_cdek_order_payload_uses_order_uuid_as_external_number() -> None:
payload = map_cdek_order_request(_make_order_request())
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(
{
"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",
}
],
}
)
assert result == CDEKOrderRegistrationResult(
order_uuid="existing-uuid",
waybill_uuid="waybill-existing",
waybill_url="https://cdek.test/waybill/existing.pdf",
)
def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
payload = map_cdek_order_request(make_init_payment_request())
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()))