Добавлен заказ накладной
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+104
-43
@@ -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)
|
||||
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 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")
|
||||
return value
|
||||
class _CamelModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
alias_generator=to_camel,
|
||||
populate_by_name=False,
|
||||
extra="forbid",
|
||||
)
|
||||
|
||||
|
||||
class DeliveryLocation(BaseModel):
|
||||
address: str = Field(min_length=1)
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user