POST /api/developer/crm/bookings/{bookingId}/contract — Загрузить договор брони
Менеджер застройщика загружает файл договора бронирования к активной брони. Операция создаёт
DealDocumentEntity типа BookingContract в статусе Uploaded, проставляет Booking.ContractDocumentId и —
для платной программы — переводит бронь Reserved → AwaitingPayment, после чего клиент
скачивает/подписывает/оплачивает договор и прикрепляет чек (booking.md,
deal-document.md). Дом полей — deal-document.md
и booking.md.
Договор принимается только application/pdf до 50 МБ (INV-DOC-3). Загружает исключительно
сторона-владелец типа BookingContract — застройщик (INV-DOC-1), иначе CRM_DOCUMENT_UPLOADER_FORBIDDEN.
На бронь допускается ровно один договор: повторная загрузка при уже заполненном ContractDocumentId →
CRM_BOOKING_CONTRACT_EXISTS (409).
Назначение
Принять от застройщика метаданные заранее загруженного в storage/CDN файла ({FileId, FileName, ContentType, SizeBytes}) и зафиксировать договор бронирования: новый документ BookingContract (загружает застройщик,
клиент скачивает — INV-DOC-1) + переход брони для платной программы. Бинарь живёт в storage/CDN — CRM хранит
только FileId и метаданные (README «хранение бинарных файлов — только FileId»).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | developer (JWT менеджера застройщика) |
| Feature group | booking |
| Статус | draft |
| Документ | docs/06-services/crm/api/developer/upload-booking-contract.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Менеджер застройщика); Operator (Менеджер ПИН — platform-scope, действие от имени застройщика) |
| Auth policy / Permissions | crm.bookings.decide (застройщик: договор/оплата/продление — booking.md); создаваемый документ подчинён crm.deal-documents.manage |
| Scope (RBAC) | организация (застройщик): record-level по брони/сделке |
| Record-level | booking.TenantId == actor.DeveloperCompanyId; актор — участник-застройщик сделки (SellManager) либо Менеджер ПИН |
| Tenant isolation | TenantId в запросе (tenanted); booking.TenantId == TenantId, иначе CRM_BOOKING_NOT_FOUND (ADR-0052) |
| Audit | create (DealDocumentEntity, UploadedByUserId = actor.UserId) + update (BookingEntity); FileName/FileId в логи — только Id |
Загружает менеджер застройщика (permission crm.bookings.decide) либо менеджер ПИН (platform-scope) —
только по брони своего TenantId = DeveloperCompanyId; чужой tenant не раскрывается
(CRM_BOOKING_NOT_FOUND). Уполномочена только сторона-владелец типа BookingContract (INV-DOC-1) — иной
актор получает CRM_DOCUMENT_UPLOADER_FORBIDDEN.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/developer/crm/bookings/{bookingId}/contract |
| Success status | 201 (документ создан) |
| Idempotency header | не применяется (повторная загрузка → CRM_BOOKING_CONTRACT_EXISTS 409) |
| Correlation | сквозной trace через заголовок traceparent |
Idempotency-заголовок не применяется: повторная загрузка при уже проставленном
Booking.ContractDocumentId не создаёт новый документ, а отбивается CRM_BOOKING_CONTRACT_EXISTS (409) —
на бронь допускается ровно один договор.
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims | Guid | Да | Required; = DeveloperCompanyId актора | Tenant isolation |
BookingId | route | Guid | Да | != Guid.Empty; существующая бронь Status = Reserved своего tenant (шаг 2) | — |
FileId | body | Guid | Да | != Guid.Empty; ссылка на объект в storage/CDN (INV-DOC-3) | — |
FileName | body | string | Да | 1..512, не пустое после trim | в логах — не пишется |
ContentType | body | string | Да | ровно application/pdf (договор — PDF; INV-DOC-3) | — |
SizeBytes | body | long | Да | > 0 and <= 52_428_800 (50 МБ, INV-DOC-3) | — |
Title | body | string? | Нет | 1..256; null → генерируется («Договор бронирования») | — |
ActorUserId | identity claims | Guid | Да | JWT застройщика; UploadedByUserId/CreatedByUserId документа | — |
Модель ответа DealDocumentModel
Проекция созданного документа (дельта дома deal-document.md); файл загрузил сам застройщик, поэтому маскирование не требуется.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
DocumentId | Guid | Да | DealDocumentEntity.Id | — | Идентификатор документа. |
BookingId | Guid | Да | BookingId | — | Бронь-владелец. |
DealId | Guid | Да | DealId | — | Сделка (deal.md). |
Type | string (enum DealDocumentType) | Да | Type | — | Всегда BookingContract. |
Status | string (enum DealDocumentStatus) | Да | Status | — | Всегда Uploaded. |
Title | string | Да | Title | — | Отображаемое название. |
FileId | Guid | Да | FileId | — | Файл в storage/CDN (скачивание — пресайн-ссылка). |
FileName | string | Да | FileName | — | Оригинальное имя. |
ContentType | string | Да | ContentType | — | application/pdf. |
SizeBytes | long | Да | SizeBytes | — | Размер файла. |
UploadedByUserId | Guid | Да | UploadedByUserId | — | Кто загрузил. |
UploadedAt | DateTimeOffset | Да | UploadedAt | — | Момент загрузки. |
BookingStatus | string (enum BookingStatus) | Да | BookingEntity.Status (после перехода) | — | AwaitingPayment (платная) / Reserved (бесплатная). |
Алгоритм
- Контекст и доступ. Выполняет менеджер застройщика (
User, permissioncrm.bookings.decide) либо менеджер ПИН (platform-scope); tenant —TenantId = DeveloperCompanyId. Идемпотентность не применяется — повторная загрузка отбивается на шаге 2 (ContractDocumentId != null). - Проверка входа и предусловий.
BookingId/FileId != Guid.Empty;FileName 1..512;ContentType == "application/pdf";SizeBytes ∈ (0, 52_428_800];Title ≤ 256(INV-DOC-3) — иначеVALIDATION_FAILED;- бронь существует в tenant застройщика — иначе
CRM_BOOKING_NOT_FOUND(чужой tenant не раскрывается); - допустимость состояния (INV-BKG):
Status == Reserved— иначеCRM_BOOKING_INVALID_STATE;now <= ReserveExpiresAt— иначеCRM_BOOKING_RESERVE_EXPIRED;ContractDocumentId == null— иначеCRM_BOOKING_CONTRACT_EXISTS; - право загрузки (INV-DOC-1): тип
BookingContractгрузит сторона-владелец — застройщик; иначеCRM_DOCUMENT_UPLOADER_FORBIDDEN.
- Что читаем. Бронь — с защитой от гонки с worker-ом
booking-expiration(INV-BKG-5); сделку брони — для guard INV-DOC-5 (сделка нетерминальна; приReserved-брони гарантировано, проверка защитная) — иначеCRM_DEAL_ALREADY_TERMINAL. - Бизнес-правила и переходы (триггер
ContractUploaded, booking.md). Создаётся документBookingContractв статусеUploaded(RequestedByUserId = null— загрузка без предварительного запроса), маппинг{FileId, FileName, ContentType, SizeBytes, Title ?? "Договор бронирования", UploadedByUserId = ActorUserId, UploadedAt = now}. ДалееBooking.ContractDocumentId = document.Id; для платной программы (ProgramTerms.IsPaid) — переходReserved → AwaitingPayment+PaymentStatus = Pending(суммаPaymentAmountснята при создании брони); для бесплатной — остаётсяReserved(AwaitingPaymentпропускается). Фиксируется доменный фактDocumentUploaded; подписчик пишет строку таймлайна «договор бронирования загружен» (INV-DOC-6). - Что меняется. Появляется документ
BookingContract; бронь получаетContractDocumentIdи (для платной) новый статус/PaymentStatus. Порядок сохранения — документ раньше брони (FKfk_bookings_contract_document_id → crm.deal_documents(id)). Конкурентный доступ — по оптимистической версии: проигравший гонку с worker-ом истечения ретраится и увидитReserveExpired→CRM_BOOKING_INVALID_STATE. - События. Интеграционное
DocumentStatusChangedEvent→ топикpin.crm.document-status-changed.v1(ContractVersion = 1,AggregateId = DocumentId):DocumentId,DealId,TenantId,DocumentType = BookingContract,FromStatus = Requested(условно — договор рождается сразуUploaded),ToStatus = Uploaded,RejectReason = null,ChangedByUserId = ActorUserId,ChangedAt = now; через outbox (дом — document-status-changed.md). Потребительnotificationsуведомляет инвестора «договор бронирования загружен — скачайте/подпишите/оплатите». Переход брони→ AwaitingPaymentсобственного Kafka-события не имеет (нет такого контракта — README); клиент узнаёт о нём изdocument-status-changed. Статус объекта недвижимости вchessboardне меняется (объект остаётся «Резерв»). - Согласованность. Документ, обновление брони и событие фиксируются в одном согласованном изменении (атомарно).
- Результат. Проекция документа
DealDocumentModel(+BookingStatusиз текущего статуса брони) → HTTP201.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | DocumentStatusChangedEvent → pin.crm.document-status-changed.v1 (ToStatus = Uploaded), outbox; контракт — document-status-changed.md |
| External calls | none синхронно (бинарь уже в storage/CDN; CRM пишет только FileId — README) |
| Cache / projections / search | none |
| Notifications | опосредованно: notifications — консюмер document-status-changed (инвестору «договор загружен, скачайте/подпишите/оплатите») |
Ошибки и failure modes
CRM_BOOKING_CONTRACT_EXISTS (повторная загрузка при уже заполненном ContractDocumentId) не путать с
CRM_BOOKING_INVALID_STATE (бронь не в Reserved) и CRM_BOOKING_RESERVE_EXPIRED (резерв истёк) — три
разных причины отказа на пересекающихся статусах брони.
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | ContentType != application/pdf, SizeBytes вне лимита, пустой FileId/FileName (INV-DOC-3) | «Проверьте файл договора (PDF, до 50 МБ)» | no retry |
CRM_BOOKING_NOT_FOUND | 404 | бронь не найдена или чужой tenant | «Бронирование не найдено» | no retry |
CRM_BOOKING_INVALID_STATE | 409 | booking.Status != Reserved | «Загрузка договора недоступна в текущем статусе брони» | no retry (перечитать состояние) |
CRM_BOOKING_RESERVE_EXPIRED | 409 | now > ReserveExpiresAt (резерв истёк) | «Срок резерва истёк» | no retry |
CRM_BOOKING_CONTRACT_EXISTS | 409 | ContractDocumentId != null (договор уже загружен) | «Договор бронирования уже загружен» | no retry |
CRM_DOCUMENT_UPLOADER_FORBIDDEN | 403 | actor — не сторона-владелец типа BookingContract (INV-DOC-1) | «Нет прав на загрузку договора» | no retry |
CRM_DEAL_ALREADY_TERMINAL | 409 | сделка в терминальном статусе (INV-DOC-5, защитная проверка) | «Сделка завершена» | no retry |
Acceptance Criteria
- Given бронь
Reserved,now <= ReserveExpiresAt,ContractDocumentId == null,ProgramTerms.IsPaid = true, When застройщик загружает валидный PDF, Then создан документBookingContract(Uploaded),Booking.ContractDocumentIdзаполнен, переходReserved → AwaitingPayment+PaymentStatus = Pending, событиеDocumentStatusChangedEvent→pin.crm.document-status-changed.v1(ToStatus = Uploaded) в outbox, ответ201. - Given та же бронь при
ProgramTerms.IsPaid = false, When загрузка договора, Then документ создан, бронь остаётсяReserved(AwaitingPaymentпропущен), ответ201. - Given
ContractDocumentId != null, When upload, ThenCRM_BOOKING_CONTRACT_EXISTS(409). - Given
now > ReserveExpiresAt, When upload, ThenCRM_BOOKING_RESERVE_EXPIRED(409). - Given
booking.Status != Reserved, When upload, ThenCRM_BOOKING_INVALID_STATE(409). - Given
ContentType != application/pdf,SizeBytes ∉ (0, 50 МБ]или пустойFileId/FileName, When upload, ThenVALIDATION_FAILED(400) по INV-DOC-3. - Given актор — не сторона-владелец типа
BookingContract(не застройщик/PinManager), When upload, ThenCRM_DOCUMENT_UPLOADER_FORBIDDEN(403) по INV-DOC-1; чужой tenant →CRM_BOOKING_NOT_FOUND(404). - Given параллельно worker
booking-expirationпереводит бронь вReserveExpired(INV-BKG-5), When загрузка проигрывает гонку по версии, Then ретрай видитCRM_BOOKING_INVALID_STATE, документ не создаётся, статус объекта вchessboardне меняется. - Given успешная загрузка договора, When событие
document-status-changedдоставлено, Then потребительnotificationsуведомляет инвестора «договор бронирования загружен — скачайте/подпишите/оплатите» (async, отдельным сервисом).
Совместимость и наблюдаемость
- Все поля
DealDocumentModel— additive optional без breaking change; удаление/переименование/смена типа — breaking (migration + rollout). Расширение допустимыхContentType— конфигурация (deal-document.md). - Logs:
booking-contract uploaded(DocumentId,BookingId,TenantId,BookingStatus;FileNameне пишется); metrics:crm_booking_contract_uploaded_total, latency p95; traces — OTel, correlation черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (2): GET /api/investor/crm/deals/{dealId}/documents/{documentId}/download — Скачать документ сделки, POST /api/investor/crm/deals/{dealId}/documents — Загрузить документ в сделку
- Use Cases (2): UC-BKG-002. Бронирование Резерв → Бронь, UC-BKG-004. Оплата платного бронирования (чек + подтверждение)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: deal-document.md (тип
BookingContract, статусы, INV-DOC-1/3/5/6), booking.md (переходReserved → AwaitingPayment,ContractDocumentId, INV-BKG-3/4), deal.md. - Событие: document-status-changed.md; карта области — README.
- Продолжение flow (платная программа): клиент прикрепляет чек, менеджер подтверждает оплату
(
ConfirmPayment, INV-BKG-4 — booking.md), затемConfirmBooking → Booked. - ADR: 0052 tenancy, 0056 simplicity-first.