Добавлен заказ накладной
This commit is contained in:
+3
-2
@@ -38,9 +38,10 @@
|
||||
| 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` |
|
||||
| 030 | DONE | 2026-04-18 | Add TBank payment notification webhook and CDEK order creation | `spec/tasks/030_add_tbank_payment_notification_webhook.md` |
|
||||
| 031 | TODO | 2026-04-18 | Validate init-payment price with CDEK tariff | `spec/tasks/031_validate_init_payment_price_with_cdek.md` |
|
||||
| 032 | TODO | 2026-05-13 | Rework init-payment contract to camelCase and structured address/contact | `spec/tasks/032_rework_init_payment_contract_camelcase.md` |
|
||||
|
||||
## Summary
|
||||
|
||||
- Total: **32**
|
||||
- TODO: **1**
|
||||
- Total: **33**
|
||||
- TODO: **2**
|
||||
- DONE: **31**
|
||||
|
||||
+59
-27
@@ -119,7 +119,15 @@
|
||||
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только 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` строкой.
|
||||
Для 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
|
||||
@@ -203,36 +211,60 @@ postal_code: str | None
|
||||
```
|
||||
|
||||
### Входная: `InitPaymentRequest`
|
||||
Контракт ручки `/api/v1/delivery/order` использует camelCase в JSON; Pydantic-
|
||||
модели хранят snake_case поля и принимают входной JSON через alias-generator.
|
||||
```
|
||||
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
|
||||
orderUuid: str
|
||||
senderAddress:
|
||||
cityId: int
|
||||
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}]
|
||||
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
|
||||
```
|
||||
|
||||
`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.
|
||||
`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`
|
||||
```
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
id: 032
|
||||
title: Rework init-payment contract to camelCase and structured address/contact
|
||||
status: TODO
|
||||
created: 2026-05-13
|
||||
---
|
||||
|
||||
## Context
|
||||
Фронт переходит на новый контракт ручки `POST /api/v1/delivery/order`. В нём
|
||||
адрес/контакт структурированы по-новому, появились флаг юр-лица и реквизиты
|
||||
(`isCompany`/`companyName`/`inn`/`kpp`/`phoneExt`), описание груза и вес
|
||||
(`content.description`), даты `pickupDate`/`deliveryDate`,
|
||||
`accountEmail`, блок `systemData` с зафиксированным тарифом, parcelType,
|
||||
docPackaging и dimensions. Поле наименования — camelCase. Это breaking change
|
||||
без обратной совместимости.
|
||||
|
||||
## Goal
|
||||
Привести бэкенд к новому контракту `/api/v1/delivery/order`, сохранив текущий
|
||||
flow «валидация цены через CDEK → создание payment URL в TBank → persist в
|
||||
PostgreSQL → возврат `payment_url`; webhook `CONFIRMED` → регистрация заказа в
|
||||
CDEK».
|
||||
|
||||
## Constraints
|
||||
- camelCase в API; внутренние имена остаются Python-friendly через Pydantic
|
||||
alias-generator. JSON принимается ТОЛЬКО в camelCase.
|
||||
- `orderUuid` и `systemData.tariff.tariffCode` приходят от фронта.
|
||||
- `systemData.tariff.price` — итоговая сумма в копейках; backend использует её
|
||||
как `amount_kopecks` для TBank без пересчёта.
|
||||
- Расширить `senderAddress` и `receiverAddress` обязательным полем `cityId: int`
|
||||
(то же, что в `/price`), чтобы резолвить CDEK city code из `cities_map`.
|
||||
- При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
|
||||
- При `parcelType='doc'` `dimensions` принимает значение `null`; CDEK packages
|
||||
отправляются без length/width/height.
|
||||
- При `parcelType='parcel'` `dimensions` обязателен.
|
||||
- Адрес для CDEK собирается строкой `"{city}, {street}, {house}, кв. {apartment}"`;
|
||||
если `apartment` пустой/отсутствует — без хвоста `, кв. ...`.
|
||||
- `pickupDate` → CDEK `shipment_point.date`; `deliveryDate` → CDEK
|
||||
`delivery_point.date` (если задан).
|
||||
- `content.description` пробрасывается в CDEK `packages[0].items[0].name` и
|
||||
`comment` верхнего уровня.
|
||||
- `phoneExt` пробрасывается в CDEK `phones[0].additional`.
|
||||
- Сохранять весь принятый payload в JSONB-колонке `payload` записи `orders`.
|
||||
- ORM-модель `orders` рефакторится: вместо колонок `sender/recipient/
|
||||
from_location/to_location/packages/services/comment/delivery_type` —
|
||||
одна колонка `payload JSONB NOT NULL`, плюс `account_email VARCHAR`.
|
||||
Колонки `order_uuid`, `payment_url`, `price`, `tariff_code`, `payment_status`,
|
||||
`tbank_payment_id`, `cdek_order_uuid`, `created_at`, `updated_at` сохраняются.
|
||||
|
||||
## Acceptance criteria
|
||||
- `POST /api/v1/delivery/order` принимает новый camelCase payload и возвращает
|
||||
`InitPaymentResponse` без изменений (`{ "payment_url": str }`).
|
||||
- Pydantic-модели валидируют: обязательность реквизитов юр-лица при
|
||||
`isCompany=true`; `parcelType='doc'` ⇒ `dimensions=null`/отсутствует;
|
||||
`parcelType='parcel'` ⇒ `dimensions` обязателен; `price > 0`.
|
||||
- Service вызывает `payment_price_validation_adapter.get_payment_price(request)`
|
||||
и `payment_adapter.create_payment_link(order_uuid, amount_kopecks)` с
|
||||
`amount_kopecks = request.systemData.tariff.price`.
|
||||
- CDEK order mapper из нового `InitPaymentRequest` формирует payload с полями:
|
||||
`number`, `type=2`, `tariff_code`, `sender`, `recipient`,
|
||||
`from_location.code/address/postal_code`, `to_location.code/address/postal_code`,
|
||||
`packages[0]` с `weight` (граммы) и опциональными dimensions,
|
||||
`packages[0].items` с описанием груза, `shipment_point.date`,
|
||||
`delivery_point.date` (если есть), `comment`.
|
||||
- При `isCompany=true` CDEK получает `contragent_type="LEGAL_ENTITY"`, `company`,
|
||||
`inn`, `kpp` для соответствующей стороны.
|
||||
- ORM `Order` хранит весь payload в `payload` JSONB; репозиторий сохраняет и
|
||||
читает его без потерь; миграция переводит таблицу со старой структуры на
|
||||
новую (drop старые колонки, добавить `payload`, `account_email`).
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Pydantic-модели `payment.py` переписаны под новый camelCase-контракт с
|
||||
обязательными валидациями.
|
||||
- [ ] CDEK order mapper и `_build_payment_price_payload` собирают payload из
|
||||
новой структуры.
|
||||
- [ ] `AggregatorService.init_payment` использует `systemData.tariff.price` и
|
||||
`orderUuid` напрямую; persist использует обновлённый `OrderData`.
|
||||
- [ ] ORM-модель `Order`, `OrderData` и репозиторий перешли на `payload` JSONB
|
||||
и `account_email`.
|
||||
- [ ] Alembic-миграция `20260513_032_rework_orders_payload.py` применяется и
|
||||
откатывается.
|
||||
- [ ] Тесты на schemas, mapper, payment validation, controller и service
|
||||
обновлены и проходят.
|
||||
- [ ] `http-client.http` обновлён под новый payload.
|
||||
|
||||
## Tests
|
||||
- `tests/controllers/v1/test_init_payment.py` — fixture/assertion обновлены под
|
||||
camelCase; добавлены кейсы валидации юр-лица и doc/parcel dimensions.
|
||||
- `tests/adapters/delivery_providers/cdek/test_order_mapper.py` —
|
||||
mapping `phoneExt → additional`, контрагент юр-лица, address composition,
|
||||
doc без dimensions, shipment/delivery point dates.
|
||||
- `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py` —
|
||||
validation payload собирается из `senderAddress.cityId`, `systemData.weight`,
|
||||
`systemData.dimensions`.
|
||||
- `tests/services/test_init_payment.py` — `init_payment` берёт сумму из
|
||||
`systemData.tariff.price`, `orderUuid` пробрасывается.
|
||||
- `tests/repositories/order/test_repository.py` (если есть) — обновлён под
|
||||
новую модель `Order`.
|
||||
|
||||
## Commands
|
||||
- `pytest tests/`
|
||||
- `alembic upgrade head` (smoke на пустой базе)
|
||||
- `python3 spec/gen_spec_index.py --check`
|
||||
Reference in New Issue
Block a user