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

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 areainvestor (JWT инвестора)
Feature groupdeal-documents
Статусdraft
Документdocs/06-services/crm/api/investor/upload-deal-document.md

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

ПроверкаЗначение
Actor typeUser (Инвестор с JWT)
Auth policy / Permissionscrm.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 isolationTenantId документа не из claims инвестора (инвестор вне tenant застройщика); наследуется = deal.TenantId = DeveloperCompanyId (ADR-0052)
Auditcreate/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-контракт

ПолеЗначение
MethodPOST
Route/api/investor/crm/deals/{dealId}/documents
Success status201 (документ загружен)
Idempotency headerне применяется (повтор после Rejected — новая запись INV-DOC-4; дедуп — на UI)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdidentity claimsGuidДаJWT инвестора → UploadedByUserId/CreatedByUserId; record-level
DealIdrouteGuidДа!= Guid.Empty; сделка, где актор — активный Investor
TypebodyDealDocumentType (enum строкой)Да{ClientPassport, SignedBookingContract, ContractDraftDocument, SignedSaleContract} — клиентские типы (INV-DOC-1); иначе CRM_DOCUMENT_UPLOADER_FORBIDDEN
DocumentIdbodyGuid?Нетесли задан — существующий Requested-документ этой сделки того же Type, адресованный актору (закрывает запрос)
FileIdbodyGuidДа!= Guid.Empty; ссылка на объект в storage/CDN (INV-DOC-3)
FileNamebodystringДа1..512, не пустое после trimPII (может содержать ФИО) — в логи не пишется
ContentTypebodystringДа{image/jpeg, application/pdf} (US-INV-12: JPG/PDF; INV-DOC-3)
SizeBytesbodylongДа> 0 and <= 52_428_800 (50 МБ, INV-DOC-3)
RequisiteSetIdbodyGuid?Нетлогическая ссылка на profile (персона: муж/жена/собственник)
Ограничения файла

Принимаются только image/jpeg и application/pdf, размер — не более 50 МБ (52_428_800 байт, INV-DOC-3); нарушение любого из условий даёт 400 VALIDATION_FAILED.

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

Проекция созданного/обновлённого документа — дельта дома deal-document.md; все enum — строкой.

ПолеТипОбязательностьИсточникMaskingОписание
DocumentIdGuidДаDealDocumentEntity.IdИдентификатор документа.
DealIdGuidДаDealDocumentEntity.DealIdСделка (deal.md).
Typestring (enum DealDocumentType)ДаTypeКлиентский тип (см. вход).
Statusstring (enum DealDocumentStatus)ДаStatusВсегда Uploaded.
TitlestringДаTitleОтображаемое название.
FileIdGuidДаFileIdФайл в storage/CDN (скачивание — пресайн-ссылка).
FileNamestringДаFileNameнаружу — только участникуОригинальное имя.
ContentTypestringДаContentTypeimage/jpeg / application/pdf.
SizeByteslongДаSizeBytesРазмер файла.
RequisiteSetIdGuid?НетRequisiteSetIdПерсона profile, если задана.
UploadedByUserIdGuidДаUploadedByUserIdКто загрузил (= актор).
UploadedAtDateTimeOffsetДаUploadedAtМомент загрузки (US-INV-13).
WasRequestedboolДаcomputed (DocumentId != null)true — закрыт ранее запрошенный документ.

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор; ActorUserId — из токена; tenant документа берётся из сделки; тело — {Type, DocumentId?, FileId, FileName, ContentType, SizeBytes, RequisiteSetId?}. Повтор после Rejected создаёт новую запись (INV-DOC-4).
  2. Проверка входа и предусловий.
    • формат: DealId != Guid.Empty; Type ∈ {ClientPassport, SignedBookingContract, ContractDraftDocument, SignedSaleContract}; FileId != Guid.Empty; FileName 1..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 (уже загружен).
  3. Что читаем. Сделку и активного участника Investor (для record-level и наследования tenantId = deal.TenantId = DeveloperCompanyId); при заданном DocumentId — запрошенный документ под запись (гонка с параллельной загрузкой/отзывом). Ветка B (DocumentId = null): целевого документа нет — создаётся новый.
  4. Бизнес-правила и переходы.
    • Ветка 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) — вне этой операции.
  5. Что меняется. В одном согласованном изменении: документ (создан/обновлён) + строка таймлайна; DealEntity не изменяется (документ — отдельная таблица). Гонка ветки A: проигравший перечитывает и, увидев статус != Requested, получает CRM_DOCUMENT_INVALID_STATE.
  6. Какие факты фиксируются. Публикуется 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).
  7. Результат. DealDocumentModel (из документа; WasRequested = DocumentId != null) → HTTP 201.

Диаграмма

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

ТипДетали
Integration eventsDocumentStatusChangedEventpin.crm.document-status-changed.v1 (ToStatus = Uploaded); контракт — document-status-changed.md
External callsnone синхронно (бинарь уже в storage/CDN; CRM пишет только FileId)
Cache / projections / searchnone
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_FAILED400Type не клиентский, ContentType не JPG/PDF, SizeBytes вне лимита, пустой FileId/FileName, Guid.Empty (INV-DOC-3)«Проверьте файл (JPG или PDF, до 50 МБ)»no retry
UNAUTHORIZED401нет/просрочен JWT инвестора«Требуется авторизация»no retry
CRM_DEAL_NOT_FOUND404сделка не найдена«Сделка не найдена»no retry
CRM_DOCUMENT_UPLOADER_FORBIDDEN403актор не активный Investor сделки, либо Type — не клиентский (INV-DOC-1)«Нет прав на загрузку этого документа»no retry
CRM_DEAL_ALREADY_TERMINAL409INV-DOC-5: deal.Status ∈ {Completed, Cancelled}«Сделка завершена — загрузка недоступна»no retry
CRM_DOCUMENT_NOT_FOUND404DocumentId задан, но документ не найден / чужая сделка / другой Type«Запрошенный документ не найден»no retry
CRM_DOCUMENT_INVALID_STATE409DocumentId задан, но документ не в статусе Requested (уже загружен)«Документ уже загружен»no retry (перечитать)
CRM_DOCUMENT_FILE_INVALID422INV-DOC-3 нарушен на уровне доменной фабрики (защитная проверка)«Файл не соответствует требованиям»no retry

Предусловия и постусловия

Предусловия (истинны до сценария):

  • актор — активный участник Investor сделки (DealMemberEntity(Role = Investor, RemovedAt is null), INV-DEAL-3) с permission crm.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 — секунды:

  1. DocumentStatusChangedEventpin.crm.document-status-changed.v1 (ToStatus = Uploaded): notifications уведомляет застройщика/участников «документ загружен, на проверке» (US-INV-14.3);
  2. дальнейший переход 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; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь).

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

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

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