"""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 = _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 _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