# spec/overview.md Контекст, специфичный для проекта агрегатора служб доставки. Глобальные правила определены в AGENTS.md. --- ## Проект **Название:** Агрегатор служб доставки (бэкенд) **Язык:** Python 3.14 **Назначение:** Агрегировать расчёты стоимости доставки от нескольких служб и отдавать единый ответ фронтенду через унифицированный API. --- ## Продуктовые требования - Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты) - Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления - Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед инициализацией оплаты доставки - Принимать запрос на инициализацию оплаты доставки через TBank и возвращать ссылку на оплату без регистрации заказа в CDEK - Принимать сумму оплаты в поле `price` в копейках - После успешного получения ссылки на оплату от TBank сохранять все данные заявки вместе со ссылкой на оплату в PostgreSQL; ошибка сохранения не блокирует возврат ссылки клиенту - Принимать HTTP-уведомления TBank о статусах платежа на отдельный webhook endpoint - При получении валидного уведомления TBank со статусом `CONFIRMED` находить сохранённую заявку в PostgreSQL и регистрировать заказ в CDEK - Остальные валидные статусы платежа TBank подтверждать без регистрации заказа в CDEK - Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге - Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru` - Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest - Для европейских стран, сопоставленных с provider id `tomtom`, использовать TomTom Search API Fuzzy Search - Конфигурация и wiring должны допускать отдельные address suggestion providers для других регионов - Опрашивать всех зарегистрированных провайдеров параллельно - Возвращать унифицированный список тарифов, отсортированных по цене - Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком - Кешировать ответы провайдеров для исключения повторных внешних запросов. Время кэширования вынести в конфиг - Применять к цене, полученной от провайдера, конфигурируемый мультипликатор и округлять итоговую цену до целого значения - Для расчёта стоимости использовать локальный справочник `cities_map` с provider-specific данными города; для CDEK брать `cdek.code` без обращения к API подсказок городов --- ## Провайдеры ### Фаза 1 - **CDEK** — https://apidoc.cdek.ru/ - Аутентификация: OAuth2 (client credentials) - Операции: расчёт тарифа - Cache TTL: 15 минут - **TBank** — https://securepay.tinkoff.ru/v2/Init - Аутентификация: `TerminalKey` и token на основе password - Операции: инициализация платежа, получение payment URL, проверка подписи HTTP-уведомлений ### Фаза 2+ - Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации --- ## Компоненты ### Controller (`app/controllers/v1/delivery.py`) - `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]` - `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]` - `POST /api/v1/delivery/order` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse` - `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке - Парсинг и валидация входных данных через Pydantic - Маппинг исключений сервиса в HTTP-ответы - Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()`, `AggregatorService.init_payment()` или `AggregatorService.handle_tbank_payment_notification()` ### Service (`app/services/aggregator.py`) - `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]` - Распределяет запросы по всем зарегистрированным провайдерам через `asyncio.gather(..., return_exceptions=True)` - Принимает от каждого провайдера список тарифов и объединяет их в единый список - Фильтрует упавшие результаты - Сортирует тарифы по цене - `AggregatorService.suggest_addresses(request: AddressSuggestRequest) -> list[AddressSuggestion]` - `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call - `AggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponse` - `AggregatorService.init_payment()` оркестрирует инициализацию платежа через injected TBank adapter dependency, затем сохраняет данные заявки вместе с `payment_url` через injected order repository; ошибка сохранения логируется, но не блокирует возврат `payment_url` - `AggregatorService.handle_tbank_payment_notification(notification: TBankPaymentNotification) -> str` - `AggregatorService.handle_tbank_payment_notification()` оркестрирует проверку подписи уведомления через injected TBank adapter, определение действия по статусу через Business Logic, чтение сохранённой заявки через injected order repository и регистрацию заказа через injected CDEK adapter при статусе `CONFIRMED` - Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` не должно повторно создавать заказ в CDEK - Валидные уведомления TBank со статусами, отличными от `CONFIRMED`, подтверждаются ответом `OK` без обращения к CDEK - Service не содержит бизнес-логики и provider HTTP-деталей ### Business Logic (`app/domain/`) - Правила сортировки и фильтрации тарифов - Правила фильтрации тарифов по `parcel_type` - Логика сравнения цен - Правила применения конфигурируемого мультипликатора к ценам провайдеров - Округление цены после применения мультипликатора до целого значения по детерминированному правилу - Правило выбора действия для TBank payment notification по статусу платежа - Нормализация входных данных (например, идентификаторов городов и правил округления веса) - Чистые функции, без IO, без зависимостей от фреймворков ### Repository (`app/repositories/cache/`) - `PriceCache` — хранилище ключ/значение на базе Redis - Операции: `get(key)`, `set(key, value, ttl)`, `invalidate(key)` - Без бизнес-решений; формирование ключа — ответственность Adapter ### Repository (`app/repositories/order/`) - `OrderRepository` — сохранение данных заявки в PostgreSQL - Операции: `create_order(session, order_data)` — сохраняет запись заявки с данными `InitPaymentRequest` и `payment_url` - Операции: `get_order_by_order_uuid(session, order_uuid)` — получает сохранённую заявку для payment notification flow - Операции: `mark_payment_status(session, order_uuid, status, payment_id)` — сохраняет последний статус платежа TBank - Операции: `mark_cdek_order_registered(session, order_uuid, cdek_order_uuid)` — сохраняет UUID заказа CDEK после успешной регистрации - SQLAlchemy ORM model таблицы `orders` в `models.py` - Без бизнес-решений; только CRUD-примитивы ### Adapter (`app/adapters/delivery_providers`) - `base.py` — абстрактный интерфейс `DeliveryProvider`: ```python class DeliveryProvider(ABC): name: str async def get_prices(self, request: DeliveryCalculationRequest) -> list[DeliveryPrice]: ... ``` - `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с) - `cdek/auth.py` — управление OAuth2-токеном - `cdek/mapper.py` — ответ CDEK → `list[DeliveryPrice]` - `cdek/order_mapper.py` — request/response mapping для CDEK order contract; публичный `init-payment` flow не вызывает регистрацию заказа в CDEK - Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case - Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API Для CDEK order contract mapper принимает `InitPaymentRequest`, сериализует `sender.phone` и `recipient.phone` в provider payload `phones` с одним элементом, не отправляет `services` при отсутствии значения, конвертирует `packages[*].weight` из килограммов в граммы и возвращает `entity.uuid` строкой. ### Adapter (`app/adapters/tbank`) - `base.py` — исключения TBank payment adapter - `client.py` — `TBankAdapter` - `TBankAdapter.create_payment_link(order_uuid: str, amount_kopecks: int) -> str` инициализирует платёж TBank и возвращает payment URL - `TBankAdapter.verify_payment_notification(notification: TBankPaymentNotification) -> None` проверяет token HTTP-уведомления TBank по top-level scalar fields, исключая `Token` и вложенные объекты - TBank adapter инкапсулирует HTTP-взаимодействие с TBank Init API, auth token, retries, timeout, serialization и error handling - TBank adapter владеет собственной секцией конфигурации `tbank_payment` с полями `init_url`, `auth.terminal_key`, `auth.password`, `timeout_seconds`, `retry_attempts`, `retry_backoff_seconds` ### Adapter (`app/adapters/postgres`) - Управление подключением к PostgreSQL: создание `AsyncEngine` и `async_sessionmaker` из конфигурации - Инкапсулирует инфраструктурные детали SQLAlchemy async engine - Владеет собственной секцией конфигурации `postgres` с обязательным полем `dsn` - Без SQL-запросов, без бизнес-логики ### Adapter (`app/adapters/address_suggestions`) - `base.py` — абстрактный интерфейс `AddressSuggestionProvider`: ```python class AddressSuggestionProvider(ABC): name: str async def suggest(self, request: AddressSuggestRequest) -> list[AddressSuggestion]: ... ``` - `dadata/client.py` — HTTP-клиент `dadata.ru` для address suggestions - `yandex_geosuggest/client.py` — HTTP-клиент Yandex Geosuggest `GET https://suggest-maps.yandex.ru/v1/suggest` - `tomtom/client.py` — HTTP-клиент TomTom Search API Fuzzy Search `GET https://api.tomtom.com/search/2/search/{query}.json` - provider-specific модули address suggestion adapters инкапсулируют внешние API-контракты, auth, serialization и error handling - В конфиге adapter layer хранится маппинг `country_code -> provider_id` для выбора address suggestion provider - Address suggestion adapters возвращают только унифицированные internal models без утечки provider-specific payload в Service - Для Yandex Geosuggest adapter возвращает `postal_code=None`, так как контракт Geosuggest не используется как источник почтового индекса - Для TomTom Search adapter использует `request.city` и `request.query` для формирования search query, передаёт `countrySet=request.country_code`, `typeahead=true`, `idxSet=PAD,Addr,Str,EPP`, а `postal_code` берёт из `address.postalCode`, если оно присутствует --- ## Reference Data - `cities_map` содержит city identifier и provider-specific данные города для price flow. - Справочник может быть частично заполнен; отсутствие provider-specific данных для города должно обрабатываться детерминированно. - Структура справочника должна допускать появление дополнительных провайдеров без изменения публичного API расчёта. --- ## Модели данных ### Входная: `DeliveryCalculationRequest` ``` entity: Enum(individual, legal) from_city: int to_city: int weight_kg: float length_cm: float width_cm: float height_cm: float parcel_type: Literal[doc, parcel] | None ``` ### Выходная: `DeliveryPrice` ``` provider: str service_name: str price: Decimal currency: str delivery_days_min: int delivery_days_max: int ``` ### Входная: `AddressSuggestRequest` ``` country_code: str city: str query: str limit: int | None ``` ### Выходная: `AddressSuggestion` ``` address: str street: str | None house: str | None flat: str | None postal_code: str | None ``` ### Входная: `InitPaymentRequest` ``` order_uuid: str price: int type: Literal[2] tariff_code: Literal[535] comment: str | None sender: name: str email: str phone: {number: str} recipient: name: str email: str phone: {number: str} from_location: address: str city: str country_code: str to_location: address: str city: str country_code: str services: list[{code: str, parameter: str}] | None packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}] ``` `price` задаётся в копейках, является обязательным целым числом и должен быть больше 0. `from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; payment flow не выполняет address suggestion lookup. `sender.phone` и `recipient.phone` представляют единственный телефон для соответствующей стороны; передача нескольких телефонов во входном API не поддерживается. `packages[*].weight` в `InitPaymentRequest` задаётся в килограммах; CDEK order mapper конвертирует его в граммы для provider payload. ### Выходная: `InitPaymentResponse` ``` payment_url: str ``` ### Входная: `TBankPaymentNotification` ``` TerminalKey: str OrderId: str Success: bool Status: str PaymentId: int ErrorCode: str Amount: int Token: str ``` `PaymentId` приходит из TBank как JSON-число (long integer) и валидируется как положительное целое. Модель уведомления должна допускать дополнительные top-level поля TBank. Проверка token использует все top-level scalar fields кроме `Token`, добавляет `Password` из `tbank_payment.auth.password`, сортирует ключи по алфавиту, конкатенирует строковые представления значений (булевы — как lowercase `true`/`false`) и сравнивает SHA-256 hash с `Token`. Вложенные объекты, включая `Data` и `Receipt`, не участвуют в проверке token. ### Выходная: TBank notification response ``` OK ``` Успешно обработанное HTTP-уведомление TBank возвращает `HTTP 200` и plain text body `OK`. --- ## Технологический стек | Задача | Инструмент | |---|---| | Web-фреймворк | FastAPI | | HTTP-клиент | httpx (async) | | Валидация | Pydantic v2 | | Кеш | Redis | | База данных | PostgreSQL (asyncpg + SQLAlchemy 2.0 async) | | Миграции | Alembic (async) | | Конфигурация | pydantic-settings | | Сервер | Uvicorn | --- ## Структура проекта ``` app/ ├── controllers/ │ ├── http_client.py │ └── v1/ │ └── delivery.py # Controller ├── services/ │ └── aggregator.py # Service ├── domain/ │ └── price.py # Business Logic ├── adapters/ │ ├── address_suggestions/ │ │ ├── base.py # Интерфейс Adapter │ │ ├── dadata/ │ │ └── client.py │ │ ├── yandex_geosuggest/ │ │ └── client.py │ │ └── tomtom/ │ │ └── client.py │ └── delivery_providers/ │ ├── base.py # Интерфейс Adapter │ └── cdek/ │ ├── client.py │ ├── auth.py │ ├── mapper.py │ └── order_mapper.py │ └── tbank/ │ ├── base.py │ └── client.py │ └── postgres/ │ └── engine.py # AsyncEngine & session factory ├── repositories/ │ ├── cache/ │ │ └── redis_cache.py # Repository (Redis) │ └── order/ │ ├── models.py # SQLAlchemy ORM model │ └── repository.py # Repository (PostgreSQL) ├── schemas/ │ ├── address.py │ ├── request.py │ ├── response.py │ └── payment.py ├── config.py alembic/ # Alembic migrations alembic.ini ``` --- ## Инфраструктура ```yaml # docker-compose сервисы app # FastAPI-приложение redis # Кеш тарифов postgres # База данных заявок ```