Перейти к основному содержимому

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. На бронь допускается ровно один договор: повторная загрузка при уже заполненном ContractDocumentIdCRM_BOOKING_CONTRACT_EXISTS (409).

Назначение

Принять от застройщика метаданные заранее загруженного в storage/CDN файла ({FileId, FileName, ContentType, SizeBytes}) и зафиксировать договор бронирования: новый документ BookingContract (загружает застройщик, клиент скачивает — INV-DOC-1) + переход брони для платной программы. Бинарь живёт в storage/CDN — CRM хранит только FileId и метаданные (README «хранение бинарных файлов — только FileId»).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areadeveloper (JWT менеджера застройщика)
Feature groupbooking
Статусdraft
Документdocs/06-services/crm/api/developer/upload-booking-contract.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (Менеджер застройщика); Operator (Менеджер ПИН — platform-scope, действие от имени застройщика)
Auth policy / Permissionscrm.bookings.decide (застройщик: договор/оплата/продление — booking.md); создаваемый документ подчинён crm.deal-documents.manage
Scope (RBAC)организация (застройщик): record-level по брони/сделке
Record-levelbooking.TenantId == actor.DeveloperCompanyId; актор — участник-застройщик сделки (SellManager) либо Менеджер ПИН
Tenant isolationTenantId в запросе (tenanted); booking.TenantId == TenantId, иначе CRM_BOOKING_NOT_FOUND (ADR-0052)
Auditcreate (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-контракт

ПолеЗначение
MethodPOST
Route/api/developer/crm/bookings/{bookingId}/contract
Success status201 (документ создан)
Idempotency headerне применяется (повторная загрузка → CRM_BOOKING_CONTRACT_EXISTS 409)
Correlationсквозной trace через заголовок traceparent
Неидемпотентность

Idempotency-заголовок не применяется: повторная загрузка при уже проставленном Booking.ContractDocumentId не создаёт новый документ, а отбивается CRM_BOOKING_CONTRACT_EXISTS (409) — на бронь допускается ровно один договор.

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = DeveloperCompanyId актораTenant isolation
BookingIdrouteGuidДа!= Guid.Empty; существующая бронь Status = Reserved своего tenant (шаг 2)
FileIdbodyGuidДа!= Guid.Empty; ссылка на объект в storage/CDN (INV-DOC-3)
FileNamebodystringДа1..512, не пустое после trimв логах — не пишется
ContentTypebodystringДаровно application/pdf (договор — PDF; INV-DOC-3)
SizeBytesbodylongДа> 0 and <= 52_428_800 (50 МБ, INV-DOC-3)
Titlebodystring?Нет1..256; null → генерируется («Договор бронирования»)
ActorUserIdidentity claimsGuidДаJWT застройщика; UploadedByUserId/CreatedByUserId документа

Модель ответа DealDocumentModel

Проекция созданного документа (дельта дома deal-document.md); файл загрузил сам застройщик, поэтому маскирование не требуется.

ПолеТипОбязательностьИсточникMaskingОписание
DocumentIdGuidДаDealDocumentEntity.IdИдентификатор документа.
BookingIdGuidДаBookingIdБронь-владелец.
DealIdGuidДаDealIdСделка (deal.md).
Typestring (enum DealDocumentType)ДаTypeВсегда BookingContract.
Statusstring (enum DealDocumentStatus)ДаStatusВсегда Uploaded.
TitlestringДаTitleОтображаемое название.
FileIdGuidДаFileIdФайл в storage/CDN (скачивание — пресайн-ссылка).
FileNamestringДаFileNameОригинальное имя.
ContentTypestringДаContentTypeapplication/pdf.
SizeByteslongДаSizeBytesРазмер файла.
UploadedByUserIdGuidДаUploadedByUserIdКто загрузил.
UploadedAtDateTimeOffsetДаUploadedAtМомент загрузки.
BookingStatusstring (enum BookingStatus)ДаBookingEntity.Status (после перехода)AwaitingPayment (платная) / Reserved (бесплатная).

Алгоритм

  1. Контекст и доступ. Выполняет менеджер застройщика (User, permission crm.bookings.decide) либо менеджер ПИН (platform-scope); tenant — TenantId = DeveloperCompanyId. Идемпотентность не применяется — повторная загрузка отбивается на шаге 2 (ContractDocumentId != null).
  2. Проверка входа и предусловий.
    • 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.
  3. Что читаем. Бронь — с защитой от гонки с worker-ом booking-expiration (INV-BKG-5); сделку брони — для guard INV-DOC-5 (сделка нетерминальна; при Reserved-брони гарантировано, проверка защитная) — иначе CRM_DEAL_ALREADY_TERMINAL.
  4. Бизнес-правила и переходы (триггер 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).
  5. Что меняется. Появляется документ BookingContract; бронь получает ContractDocumentId и (для платной) новый статус/PaymentStatus. Порядок сохранения — документ раньше брони (FK fk_bookings_contract_document_id → crm.deal_documents(id)). Конкурентный доступ — по оптимистической версии: проигравший гонку с worker-ом истечения ретраится и увидит ReserveExpiredCRM_BOOKING_INVALID_STATE.
  6. События. Интеграционное 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 не меняется (объект остаётся «Резерв»).
  7. Согласованность. Документ, обновление брони и событие фиксируются в одном согласованном изменении (атомарно).
  8. Результат. Проекция документа DealDocumentModel (+ BookingStatus из текущего статуса брони) → HTTP 201.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsDocumentStatusChangedEventpin.crm.document-status-changed.v1 (ToStatus = Uploaded), outbox; контракт — document-status-changed.md
External callsnone синхронно (бинарь уже в storage/CDN; CRM пишет только FileId — README)
Cache / projections / searchnone
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_FAILED400ContentType != application/pdf, SizeBytes вне лимита, пустой FileId/FileName (INV-DOC-3)«Проверьте файл договора (PDF, до 50 МБ)»no retry
CRM_BOOKING_NOT_FOUND404бронь не найдена или чужой tenant«Бронирование не найдено»no retry
CRM_BOOKING_INVALID_STATE409booking.Status != Reserved«Загрузка договора недоступна в текущем статусе брони»no retry (перечитать состояние)
CRM_BOOKING_RESERVE_EXPIRED409now > ReserveExpiresAt (резерв истёк)«Срок резерва истёк»no retry
CRM_BOOKING_CONTRACT_EXISTS409ContractDocumentId != null (договор уже загружен)«Договор бронирования уже загружен»no retry
CRM_DOCUMENT_UPLOADER_FORBIDDEN403actor — не сторона-владелец типа BookingContract (INV-DOC-1)«Нет прав на загрузку договора»no retry
CRM_DEAL_ALREADY_TERMINAL409сделка в терминальном статусе (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, событие DocumentStatusChangedEventpin.crm.document-status-changed.v1 (ToStatus = Uploaded) в outbox, ответ 201.
  • Given та же бронь при ProgramTerms.IsPaid = false, When загрузка договора, Then документ создан, бронь остаётся Reserved (AwaitingPayment пропущен), ответ 201.
  • Given ContractDocumentId != null, When upload, Then CRM_BOOKING_CONTRACT_EXISTS (409).
  • Given now > ReserveExpiresAt, When upload, Then CRM_BOOKING_RESERVE_EXPIRED (409).
  • Given booking.Status != Reserved, When upload, Then CRM_BOOKING_INVALID_STATE (409).
  • Given ContentType != application/pdf, SizeBytes ∉ (0, 50 МБ] или пустой FileId/FileName, When upload, Then VALIDATION_FAILED (400) по INV-DOC-3.
  • Given актор — не сторона-владелец типа BookingContract (не застройщик/PinManager), When upload, Then CRM_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; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы

  • Сущности: 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.