убран SDD, добавлен uv, фикс билда
Deploy / deploy (push) Failing after 2m28s

This commit is contained in:
Раис Юсупалиев
2026-06-19 06:07:30 +03:00
parent 5f85006e1d
commit ed33357af2
51 changed files with 1130 additions and 4783 deletions
+371
View File
@@ -0,0 +1,371 @@
# 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` и собирает provider payload:
- `number = orderUuid`, `type = 2`, `tariff_code = systemData.tariff.tariffCode`.
- `sender`/`recipient` маппятся из `senderContact`/`receiverContact`: `name = fullName`, `email`, `phones = [{number, additional: phoneExt}]`. При `isCompany=true` добавляются `contragent_type="LEGAL_ENTITY"`, `company`, `inn`, `kpp`.
- `from_location`/`to_location` собираются из `senderAddress`/`receiverAddress`: `code` берётся из `cities_map` по `cityId`, `address` склеивается строкой `"{city}, {street}, {house}, кв. {apartment}"` (хвост `кв.` опускается при пустом `apartment`), `postal_code = zip`.
- `packages[0]` содержит `number = orderUuid`, `weight = round(float(systemData.weight) * 1000)` в граммах; при `parcelType='parcel'` добавляются `length`, `width`, `height` из `systemData.dimensions`; при `parcelType='doc'` габариты не передаются.
- `packages[0].items[0]` пробрасывает `content.description` как `name`.
- `shipment_point.date = pickupDate.date()`, `delivery_point.date = deliveryDate.date()` (если задан).
- Поле верхнего уровня `comment` собирается из `content.description` (если есть).
- Возвращает `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`
Контракт ручки `/api/v1/delivery/order` использует camelCase в JSON; Pydantic-
модели хранят snake_case поля и принимают входной JSON через alias-generator.
```
orderUuid: str
senderAddress:
cityId: int
city: str
street: str
house: str
apartment: str | None
zip: str
comment: str | None
senderContact:
fullName: str
email: str | None
phone: str
phoneExt: str | None
isCompany: bool
companyName: str | None # обязателен при isCompany=true
inn: str | None # обязателен при isCompany=true
kpp: str | None # обязателен при isCompany=true
receiverAddress: <структура senderAddress>
receiverContact: <структура senderContact>
content:
description: str | None
pickupDate: datetime # ISO 8601
deliveryDate: datetime | None # ISO 8601
accountEmail: str
systemData:
tariff:
provider: str
serviceName: str
price: int # копейки
deliveryDaysMin: int
deliveryDaysMax: int
tariffCode: int
parcelType: Literal[doc, parcel]
docPackaging: Literal[envelope, bag] | None # для doc
weight: str
dimensions: # null/отсутствует для doc
length: str
width: str
height: str
```
`systemData.tariff.price` задаётся в копейках, является обязательным целым
числом и должен быть больше 0; backend использует его как сумму платежа
TBank без пересчёта.
`senderAddress.cityId` и `receiverAddress.cityId` используют общий справочник
`cities_map` (тот же идентификатор, что и в `DeliveryCalculationRequest`).
Для `parcelType='doc'` `systemData.dimensions` отсутствует или равен `null`,
для `parcelType='parcel'` — обязателен.
При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
`pickupDate` и `deliveryDate` принимаются и сохраняются как ISO datetime.
### Выходная: `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 # База данных заявок
```