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

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
+49 -25
View File
@@ -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,
}
for package in request.packages
],
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],
}
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: