368 lines
12 KiB
Python
368 lines
12 KiB
Python
"""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,
|
|
SystemData,
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CDEKOrderInfo:
|
|
order_uuid: str
|
|
status_code: str | None
|
|
waybill_uuid: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CDEKWaybillInfo:
|
|
waybill_uuid: str
|
|
url: str | None
|
|
|
|
|
|
def map_cdek_order_request(
|
|
request: InitPaymentRequest, order_uuid: str
|
|
) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"number": order_uuid,
|
|
"type": 2,
|
|
"tariff_code": _to_cdek_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, order_uuid)],
|
|
}
|
|
if request.content.description:
|
|
payload["comment"] = request.content.description
|
|
return payload
|
|
|
|
|
|
def _to_cdek_tariff_code(tariff_code: str) -> int:
|
|
try:
|
|
return int(tariff_code)
|
|
except (TypeError, ValueError) as exc:
|
|
raise CDEKOrderMappingError(
|
|
f"CDEK tariff_code must be numeric: {tariff_code!r}."
|
|
) from exc
|
|
|
|
|
|
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.")
|
|
|
|
order_uuid = entity.get("uuid")
|
|
if not isinstance(order_uuid, str) or not order_uuid:
|
|
raise CDEKOrderMappingError("CDEK order response must include entity.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,
|
|
) -> CDEKOrderRegistrationResult | None:
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
|
|
if not _contains_cdek_duplicate_error_code(payload):
|
|
return None
|
|
|
|
try:
|
|
return map_cdek_order_response(payload)
|
|
except CDEKOrderMappingError:
|
|
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_cdek_order_info_response(payload: dict[str, Any]) -> CDEKOrderInfo:
|
|
entity = payload.get("entity")
|
|
if not isinstance(entity, dict):
|
|
raise CDEKOrderMappingError(
|
|
"CDEK order info response must include entity object."
|
|
)
|
|
|
|
order_uuid = entity.get("uuid")
|
|
if not isinstance(order_uuid, str) or not order_uuid:
|
|
raise CDEKOrderMappingError(
|
|
"CDEK order info response must include entity.uuid."
|
|
)
|
|
|
|
status_code = _invalid_create_request_status(
|
|
payload.get("requests")
|
|
) or _latest_status_code(entity.get("statuses"))
|
|
waybill_uuid, _ = _extract_waybill(payload)
|
|
return CDEKOrderInfo(
|
|
order_uuid=order_uuid,
|
|
status_code=status_code,
|
|
waybill_uuid=waybill_uuid,
|
|
)
|
|
|
|
|
|
def map_cdek_waybill_info_response(payload: dict[str, Any]) -> CDEKWaybillInfo:
|
|
entity = payload.get("entity")
|
|
if not isinstance(entity, dict):
|
|
raise CDEKOrderMappingError(
|
|
"CDEK waybill info response must include entity object."
|
|
)
|
|
|
|
waybill_uuid = entity.get("uuid")
|
|
if not isinstance(waybill_uuid, str) or not waybill_uuid:
|
|
raise CDEKOrderMappingError(
|
|
"CDEK waybill info response must include entity.uuid."
|
|
)
|
|
|
|
url_value = entity.get("url")
|
|
waybill_url = url_value if isinstance(url_value, str) and url_value else None
|
|
return CDEKWaybillInfo(waybill_uuid=waybill_uuid, url=waybill_url)
|
|
|
|
|
|
def _latest_status_code(statuses: object) -> str | None:
|
|
if not isinstance(statuses, list) or not statuses:
|
|
return None
|
|
dated: list[tuple[str, str]] = []
|
|
for entry in statuses:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
code = entry.get("code")
|
|
if not isinstance(code, str) or not code:
|
|
continue
|
|
date_time = entry.get("date_time")
|
|
date_time_key = date_time if isinstance(date_time, str) else ""
|
|
dated.append((date_time_key, code))
|
|
if not dated:
|
|
return None
|
|
dated.sort(key=lambda item: item[0])
|
|
return dated[-1][1]
|
|
|
|
|
|
def _invalid_create_request_status(requests: object) -> str | None:
|
|
if not isinstance(requests, list):
|
|
return None
|
|
for entry in requests:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if entry.get("type") != "CREATE" or entry.get("state") != "INVALID":
|
|
continue
|
|
errors = entry.get("errors")
|
|
if isinstance(errors, list) and errors:
|
|
return "INVALID"
|
|
return None
|
|
|
|
|
|
def _extract_waybill(payload: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
entity = payload.get("entity")
|
|
related_entities = (
|
|
entity.get("related_entities")
|
|
if isinstance(entity, dict)
|
|
else None
|
|
)
|
|
if not isinstance(related_entities, list):
|
|
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 {
|
|
"code": resolve_cdek_city_code(address.city_id),
|
|
"address": compose_address_line(address),
|
|
"postal_code": address.zip,
|
|
}
|
|
|
|
|
|
def _map_package(request: InitPaymentRequest, order_uuid: str) -> dict[str, Any]:
|
|
system_data: SystemData = request.system_data
|
|
weight_grams = kilograms_string_to_grams(request.system_data.weight)
|
|
description = request.content.description or order_uuid
|
|
package: dict[str, Any] = {
|
|
"number": 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:
|
|
if isinstance(payload, dict):
|
|
code = payload.get("code")
|
|
if isinstance(code, str) and code in _CDEK_DUPLICATE_ORDER_ERROR_CODES:
|
|
return True
|
|
return any(
|
|
_contains_cdek_duplicate_error_code(value)
|
|
for value in payload.values()
|
|
)
|
|
if isinstance(payload, list):
|
|
return any(_contains_cdek_duplicate_error_code(item) for item in payload)
|
|
return False
|
|
|
|
|
|
def _find_first_uuid(payload: object) -> str | None:
|
|
if isinstance(payload, dict):
|
|
value = payload.get("uuid")
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
for nested_value in payload.values():
|
|
nested_uuid = _find_first_uuid(nested_value)
|
|
if nested_uuid is not None:
|
|
return nested_uuid
|
|
if isinstance(payload, list):
|
|
for item in payload:
|
|
nested_uuid = _find_first_uuid(item)
|
|
if nested_uuid is not None:
|
|
return nested_uuid
|
|
return None
|