Добавлен заказ накладной

This commit is contained in:
Раис Юсупалиев
2026-05-23 17:29:56 +03:00
parent 39cd5ddc7a
commit c494d50566
21 changed files with 1384 additions and 1126 deletions
+3 -2
View File
@@ -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
View File
@@ -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`