POST /api/investor/crm/deals/{dealId}/documents — Загрузить документ в сделку
Инвестор загружает в сделку паспортные/договорные документы (US-INV-12): скан паспорта, подписанный договор
бронирования, документы для формирования ДДУ/ДКП, подписанный договор купли-продажи. Операция либо
закрывает ранее запрошенный DealDocumentEntity в статусе Requested
(переход Requested → Uploaded, закрывает парный таймлайн-DocumentRequested → Done), либо создаёт новый
документ сразу в статусе Uploaded ([*] → Uploaded, например паспорт на этапе брони без формального
запроса). Бинарь уже лежит в storage/CDN — CRM хранит только FileId и метаданные. Дом полей —
deal-document.md.
Назначение
Точка входа вкладки «Документы» карточки сделки ЛК инвестора (/profile/deals/[id], US-INV-12/13): инвестор
как сторона-владелец клиентских типов документов прикрепляет файл к сделке, где он — активный участник
Investor. Для типов с проверкой документ затем поступает на проверку застройщику (StartReview → UnderReview, область developer — вне этой операции).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | investor (JWT инвестора) |
| Feature group | deal-documents |
| Статус | draft |
| Документ | docs/06-services/crm/api/investor/upload-deal-document.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Инвестор с JWT) |
| Auth policy / Permissions | crm.deal-documents.manage на record-level: актор — активный участник Investor сделки (cross-tenant, не tenant-scope) |
| Scope (RBAC) | не платформенный tenant застройщика — доступ по участию в сделке (record-level) |
| Record-level | активный DealMemberEntity(DealId, Role = Investor, RemovedAt is null).UserId == ActorUserId (INV-DEAL-3) |
| Tenant isolation | TenantId документа не из claims инвестора (инвестор вне tenant застройщика); наследуется = deal.TenantId = DeveloperCompanyId (ADR-0052) |
| Audit | create/update (DealDocumentEntity, UploadedByUserId = ActorUserId); FileName/FileId в логи — только Id; ClientPassport/ContractDraftDocument — Sensitive/PII (deal-document.md) |
Загрузка разрешена только активному участнику Investor сделки (record-level, INV-DEAL-3) — это не
platform-scope застройщика, а cross-tenant проверка по участию. TenantId документа не берётся из claims
инвестора, а наследуется от сделки (= deal.TenantId = DeveloperCompanyId, ADR-0052).
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/investor/crm/deals/{dealId}/documents |
| Success status | 201 (документ загружен) |
| Idempotency header | не применяется (повтор после Rejected — новая запись INV-DOC-4; дедуп — на UI) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
ActorUserId | identity claims | Guid | Да | JWT инвестора → UploadedByUserId/CreatedByUserId; record-level | — |
DealId | route | Guid | Да | != Guid.Empty; сделка, где актор — активный Investor | — |
Type | body | DealDocumentType (enum строкой) | Да | ∈ {ClientPassport, SignedBookingContract, ContractDraftDocument, SignedSaleContract} — клиентские типы (INV-DOC-1); иначе CRM_DOCUMENT_UPLOADER_FORBIDDEN | — |
DocumentId | body | Guid? | Нет | если задан — существующий Requested-документ этой сделки того же Type, адресованный актору (закрывает запрос) | — |
FileId | body | Guid | Да | != Guid.Empty; ссылка на объект в storage/CDN (INV-DOC-3) | — |
FileName | body | string | Да | 1..512, не пустое после trim | PII (может содержать ФИО) — в логи не пишется |
ContentType | body | string | Да | ∈ {image/jpeg, application/pdf} (US-INV-12: JPG/PDF; INV-DOC-3) | — |
SizeBytes | body | long | Да | > 0 and <= 52_428_800 (50 МБ, INV-DOC-3) | — |
RequisiteSetId | body | Guid? | Нет | логическая ссылка на profile (персона: муж/жена/собственник) | — |
Принимаются только image/jpeg и application/pdf, размер — не более 50 МБ (52_428_800 байт, INV-DOC-3);
нарушение любого из условий даёт 400 VALIDATION_FAILED.
Модель ответа DealDocumentModel
Проекция созданного/обновлённого документа — дельта дома deal-document.md; все enum — строкой.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
DocumentId | Guid | Да | DealDocumentEntity.Id | — | Идентификатор документа. |
DealId | Guid | Да | DealDocumentEntity.DealId | — | Сделка (deal.md). |
Type | string (enum DealDocumentType) | Да | Type | — | Клиентский тип (см. вход). |
Status | string (enum DealDocumentStatus) | Да | Status | — | Всегда Uploaded. |
Title | string | Да | Title | — | Отображаемое название. |
FileId | Guid | Да | FileId | — | Файл в storage/CDN (скачивание — пресайн-ссылка). |
FileName | string | Да | FileName | наружу — только участнику | Оригинальное имя. |
ContentType | string | Да | ContentType | — | image/jpeg / application/pdf. |
SizeBytes | long | Да | SizeBytes | — | Размер файла. |
RequisiteSetId | Guid? | Нет | RequisiteSetId | — | Персона profile, если задана. |
UploadedByUserId | Guid | Да | UploadedByUserId | — | Кто загрузил (= актор). |
UploadedAt | DateTimeOffset | Да | UploadedAt | — | Момент загрузки (US-INV-13). |
WasRequested | bool | Да | computed (DocumentId != null) | — | true — закрыт ранее запрошенный документ. |
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор;
ActorUserId— из токена; tenant документа берётся из сделки; тело —{Type, DocumentId?, FileId, FileName, ContentType, SizeBytes, RequisiteSetId?}. Повтор послеRejectedсоздаёт новую запись (INV-DOC-4). - Проверка входа и предусловий.
- формат:
DealId != Guid.Empty;Type ∈ {ClientPassport, SignedBookingContract, ContractDraftDocument, SignedSaleContract};FileId != Guid.Empty;FileName1..512не пустое после trim;ContentType ∈ {image/jpeg, application/pdf}(INV-DOC-3);SizeBytes ∈ (0, 52_428_800]— иначеVALIDATION_FAILED; - существование сделки: если сделки нет →
CRM_DEAL_NOT_FOUND; - право загрузки (INV-DOC-1, record-level): актор должен быть активным участником
Investorсделки; иначе →CRM_DOCUMENT_UPLOADER_FORBIDDEN(застройщицкие типы отсечены на уровне формата — их инвестор не грузит); - терминальность сделки (INV-DOC-5):
deal.Status ∉ {Completed, Cancelled}— иначеCRM_DEAL_ALREADY_TERMINAL; - целевой запрос (если
DocumentIdзадан): документ должен существовать, принадлежать этой сделке и иметь тот жеType— иначеCRM_DOCUMENT_NOT_FOUND; его статус должен бытьRequested— иначеCRM_DOCUMENT_INVALID_STATE(уже загружен).
- формат:
- Что читаем. Сделку и активного участника
Investor(для record-level и наследованияtenantId = deal.TenantId = DeveloperCompanyId); при заданномDocumentId— запрошенный документ под запись (гонка с параллельной загрузкой/отзывом). Ветка B (DocumentId = null): целевого документа нет — создаётся новый. - Бизнес-правила и переходы.
- Ветка A (закрытие запроса): у существующего документа проставляются
FileId/FileName/ContentType/SizeBytes/RequisiteSetId, переходRequested → Uploaded(domain-guard INV-DOC-3),UploadedByUserId = ActorUserId,UploadedAt = now. - Ветка B (
[*] → Uploaded): создаётся новый документStatus = Uploadedс теми же полями,TenantId = deal.TenantId,Title = <дефолт по типу>,requestedByUserId = null. - обе ветки: добавляется/закрывается строка таймлайна — ветка A закрывает парный
DocumentRequested → Done, ветка B добавляет строкуDocumentUploaded(Status = Done),Payload = TimelineDocumentPayload { DocumentId, DocumentType = Type, Title, RequestComment? }(INV-TLE-2/3). ДальнейшийUploaded → UnderReviewдля типов с проверкой берёт застройщик (StartReview, область developer) — вне этой операции.
- Ветка A (закрытие запроса): у существующего документа проставляются
- Что меняется. В одном согласованном изменении: документ (создан/обновлён) + строка таймлайна;
DealEntityне изменяется (документ — отдельная таблица). Гонка ветки A: проигравший перечитывает и, увидев статус!= Requested, получаетCRM_DOCUMENT_INVALID_STATE. - Какие факты фиксируются. Публикуется
DocumentStatusChangedEvent→ топикpin.crm.document-status-changed.v1(ContractVersion = 1,AggregateType = "DealDocument") с payload{DocumentId, DealId, TenantId = deal.TenantId, DocumentType = Type, FromStatus = Requested(ветка A; для ветки B — условный «файла не было», документ рождается сразуUploaded, как в upload-booking-contract.md),ToStatus = Uploaded,RejectReason = null, ChangedByUserId = ActorUserId, ChangedAt = now}; контракт — document-status-changed.md. Потребительnotificationsуведомляет застройщика/участников «документ загружен, на проверке» (US-INV-14.3). - Результат.
DealDocumentModel(из документа;WasRequested = DocumentId != null) → HTTP201.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | DocumentStatusChangedEvent → pin.crm.document-status-changed.v1 (ToStatus = Uploaded); контракт — document-status-changed.md |
| External calls | none синхронно (бинарь уже в storage/CDN; CRM пишет только FileId) |
| Cache / projections / search | none |
| Notifications | опосредованно: notifications — потребитель document-status-changed (застройщику «документ загружен, на проверке»; инвестору — статусы, US-INV-14.3) |
Ошибки и failure modes
Два конфликтных (409) кода отражают гонки состояния: CRM_DEAL_ALREADY_TERMINAL — сделка уже завершена/
отменена (INV-DOC-5); CRM_DOCUMENT_INVALID_STATE — целевой Requested-документ уже закрыт параллельной
загрузкой (см. «Гонка закрытия запроса» в Acceptance).
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | Type не клиентский, ContentType не JPG/PDF, SizeBytes вне лимита, пустой FileId/FileName, Guid.Empty (INV-DOC-3) | «Проверьте файл (JPG или PDF, до 50 МБ)» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT инвестора | «Требуется авторизация» | no retry |
CRM_DEAL_NOT_FOUND | 404 | сделка не найдена | «Сделка не найдена» | no retry |
CRM_DOCUMENT_UPLOADER_FORBIDDEN | 403 | актор не активный Investor сделки, либо Type — не клиентский (INV-DOC-1) | «Нет прав на загрузку этого документа» | no retry |
CRM_DEAL_ALREADY_TERMINAL | 409 | INV-DOC-5: deal.Status ∈ {Completed, Cancelled} | «Сделка завершена — загрузка недоступна» | no retry |
CRM_DOCUMENT_NOT_FOUND | 404 | DocumentId задан, но документ не найден / чужая сделка / другой Type | «Запрошенный документ не найден» | no retry |
CRM_DOCUMENT_INVALID_STATE | 409 | DocumentId задан, но документ не в статусе Requested (уже загружен) | «Документ уже загружен» | no retry (перечитать) |
CRM_DOCUMENT_FILE_INVALID | 422 | INV-DOC-3 нарушен на уровне доменной фабрики (защитная проверка) | «Файл не соответствует требованиям» | no retry |
Предусловия и постусловия
Предусловия (истинны до сценария):
- актор — активный участник
Investorсделки (DealMemberEntity(Role = Investor, RemovedAt is null), INV-DEAL-3) с permissioncrm.deal-documents.manage(record-level, cross-tenant); - сделка нетерминальна (
Status ∉ {Completed, Cancelled}, INV-DOC-5); - бинарь уже загружен во внешний storage/CDN — CRM хранит только
FileIdи метаданные; тип документа — клиентский (INV-DOC-1).
Постусловия (гарантированы после успеха):
- документ создан/обновлён в статусе
Uploaded(ветка A:Requested → Uploaded, закрыт парный запрос; ветка B:[*] → Uploaded),UploadedByUserId = actor,UploadedAtпроставлен; - в ленту добавлена/закрыта строка таймлайна (
DocumentUploadedили закрытиеDocumentRequested → Done) в одном согласованном изменении;DealEntityне изменён; - опубликовано интеграционное
DocumentStatusChangedEvent(топикpin.crm.document-status-changed.v1,ToStatus = Uploaded) — атомарно с мутацией.
Интеграционная последовательность (после коммита)
Синхронно в запросе: проверки доступа/формата/состояния → выбор ветки A/B → мутация документа + строки таймлайна + фиксация исходящего события → 201 клиенту (документ виден в статусе Uploaded, строка — в «Ходе сделки»). Далее — асинхронно, событие доставляется гарантированно (at-least-once), окно eventual-consistency — секунды:
DocumentStatusChangedEvent→pin.crm.document-status-changed.v1(ToStatus = Uploaded):notificationsуведомляет застройщика/участников «документ загружен, на проверке» (US-INV-14.3);- дальнейший переход
Uploaded → UnderReviewдля типов с проверкой инициирует застройщик отдельной операцией (область developer) — вне этой операции; до неё документ остаётсяUploaded.
Что предоставляет: DocumentStatusChangedEvent { DocumentId, DealId, TenantId, DocumentType, FromStatus, ToStatus = Uploaded, RejectReason = null, ChangedByUserId, ChangedAt }, ContractVersion = 1, AggregateType = "DealDocument", ключ партиционирования — DealId (порядок смен статусов документа в пределах сделки сохраняется). Что ожидает: FileId уже указывает на загруженный бинарь (storage/CDN); CRM файл синхронно не читает. Идемпотентность/отказы: естественного dedup-ключа нет — повтор после Rejected создаёт новую запись (INV-DOC-4), дедуп двойных сабмитов — на UI; доставка события идемпотентна на стороне консюмера по DocumentId + ToStatus; при недоступности notifications — ретраи по политике консюмера, при исчерпании — DLQ (теряется только уведомление; документ и таймлайн уже сохранены).
Acceptance Criteria
- Happy path (ветка B). Дано: сделка нетерминальна, актор — активный
Investor,DocumentIdне задан. Когда:POST …/documents {Type=ClientPassport, FileId, ContentType=application/pdf, …}. Тогда:201,Status = Uploaded,WasRequested = false, добавлена строкаDocumentUploaded, опубликовано событиеToStatus = Uploaded. - Happy path (ветка A). Дано: существует
Requested-документ того жеType, адресованный актору. Когда:POSTс этимDocumentId. Тогда:201,Requested → Uploaded,WasRequested = true, парныйDocumentRequestedзакрыт→ Done. - Тип не JPG/PDF или размер > 50 МБ. Когда:
ContentType = image/gifилиSizeBytes > 52_428_800. Тогда:400 VALIDATION_FAILED(INV-DOC-3). - Застройщицкий тип. Дано:
Type = SaleContract(не клиентский). Когда:POST. Тогда: отклонение (CRM_DOCUMENT_UPLOADER_FORBIDDENпо INV-DOC-1 — инвестор такой тип не грузит). - Терминальная сделка. Дано:
Status ∈ {Completed, Cancelled}. Когда:POST. Тогда:409 CRM_DEAL_ALREADY_TERMINAL(INV-DOC-5). - Целевой документ уже загружен. Дано:
DocumentIdуказывает на документ в статусеUploaded. Когда:POST. Тогда:409 CRM_DOCUMENT_INVALID_STATE. - Чужой/несоответствующий
DocumentId. Дано:DocumentIdдругой сделки или другогоType. Когда:POST. Тогда:404 CRM_DOCUMENT_NOT_FOUND. - Гонка закрытия запроса. Дано: два инвестора-участника грузят один
Requested-документ. Когда: обе загрузки. Тогда: победитель —201; проигравший перечитывает и получает409 CRM_DOCUMENT_INVALID_STATE. - Актор не участник. Дано: актор не активный
Investorсделки. Когда:POST. Тогда:403 CRM_DOCUMENT_UPLOADER_FORBIDDEN. - Уведомление отложено. Дано:
notificationsвременно недоступен. Когда: успешная загрузка. Тогда:201(документ сохранён), событие доставится позже/уйдёт в DLQ — сделка не страдает.
Совместимость и наблюдаемость
- Новые optional поля запроса/ответа — additive (без breaking); удаление/переименование/смена типа поля или
значения enum — breaking (migration + rollout). Unknown значение
Type/ContentTypeво входе —VALIDATION_FAILED; расширение допустимыхContentType— конфигурация (deal-document.md). - Logs:
deal-document uploaded(DocumentId,DealId,Type,WasRequested;FileName/FileId— не в логи, толькоId); metrics:crm_deal_documents_uploaded_total{type,result}, latency p95 (< 500 мс, SLO команд); traces — OTel, correlation черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/(будущий путь).
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: deal-document.md (
DealDocumentType/DealDocumentStatus, INV-DOC-1/3/4/5/6, переходыRequested → Uploaded/[*] → Uploaded), deal.md (статус-guard, активныйInvestorINV-DEAL-3), deal-timeline-event.md (DocumentUploadedзакрываетDocumentRequested → Done, INV-TLE-3,TimelineDocumentPayload). - Событие: document-status-changed.md; запрос документа — document-requested.md; застройщицкие аналоги — request-contract-documents.md, upload-booking-contract.md.
- Контекст: README; tenancy — ADR-0052.