POST /api/developer/crm/deals/{dealId}/sale-contract — Загрузить договор ДДУ/ДКП
Менеджер застройщика загружает подготовленный договор ДДУ/ДКП по сделке; клиент затем скачивает файл
(US-DEV-03, US-INV-20), а подписанную застройщиком версию загружают тем же методом (US-DEV-07) — новой
записью типа SaleContract (append-only история версий, INV-DOC-4). Создаёт запись-дочку
агрегата сделки DealDocumentEntity сразу в статусе Uploaded (тип грузит
застройщик, проверки клиентом нет — рабочий финал) и публикует
DocumentStatusChangedEvent, по которому notifications уведомляет
клиента «договор готов к скачиванию».
Назначение
Принять от менеджера застройщика метаданные загруженного в storage/CDN файла договора ДДУ/ДКП (FileId +
имя/MIME/размер) и завести иммутабельную запись SaleContract у сделки, чтобы инвестор увидел документ на
вкладке «Документы» карточки сделки и скачал по пресайн-ссылке.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | developer |
| Feature group | deal-documents |
| Статус | draft |
| Документ | docs/06-services/crm/api/developer/upload-sale-contract.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Менеджер застройщика) |
| Auth policy / Permissions | crm.deal-documents.manage (JWT identity) |
| Scope (RBAC) | организация (застройщик) — TenantId = DeveloperCompanyId |
| Record-level | актор — активный участник сделки с ролью SellManager (или PinManager), сторона-владелец типа SaleContract — INV-DOC-1 |
| Tenant isolation | TenantId в каждом запросе (= DeveloperCompanyId сделки); разграничение применяется автоматически |
| Audit | create (UploadedByUserId/CreatedByUserId = актор); сам файл договора — за пресайн-ссылкой storage, в логи метаданные без содержимого |
Permission crm.deal-documents.manage необходим, но недостаточен: дополнительно action проверяет
record-level условие INV-DOC-1 — актор должен быть активным участником сделки с ролью SellManager или
PinManager. Нарушение любого из двух условий → CRM_DOCUMENT_UPLOADER_FORBIDDEN (403).
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/developer/crm/deals/{dealId}/sale-contract |
| Success status | 201 (создана новая запись документа) |
| Idempotency header | не применяется (append-only: каждая загрузка — новая версия/запись, INV-DOC-4) |
| Correlation | сквозной trace через заголовок traceparent |
Метод append-only (INV-DOC-4): каждый вызов — новая запись SaleContract, а не перезапись предыдущей.
Именно так подписанная застройщиком версия (US-DEV-07) добавляется тем же методом поверх ранее
загруженного черновика, не удаляя историю.
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims | Guid | Да | Required (DeveloperCompanyId) | Tenant isolation |
DealId | route | Guid | Да | != Guid.Empty; существующая сделка TenantId (шаг 2) | — |
FileId | body | Guid | Да | != Guid.Empty; ссылка на storage/CDN (tenantId + fileId → presignedDownloadUrl) — INV-DOC-3 | — |
FileName | body | string | Да | trim, 1..512 | оригинальное имя, не в логи целиком |
ContentType | body | string | Да | whitelist application/pdf (договор — PDF; конфигурация MIME) — INV-DOC-3 | — |
SizeBytes | body | long | Да | > 0 and <= 52_428_800 (50 МБ, конфигурация) — INV-DOC-3 | — |
Title | body | string? | Нет | 0..256; default «Договор ДДУ/ДКП» по Deal.ContractType | — |
RequisiteSetId | body | Guid? | Нет | логическая ссылка на profile (договор на конкретную персону/собственника) | — |
Тип документа фиксирован системой (DealDocumentType.SaleContract) — не входной параметр. ContentType —
строгий whitelist (не голый MIME), enum-статусы и типы — строкой. Дом полей — deal-document.md.
Модель ответа DealDocumentModel
Проекция созданной записи DealDocumentEntity (здесь только возвращаемая
дельта):
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | DealDocumentEntity.Id | — | Идентификатор новой записи документа. |
DealId | Guid | Да | DealDocumentEntity.DealId | — | Сделка (deal.md). |
Type | DealDocumentType | Да | DealDocumentEntity.Type (enum строкой) | — | Всегда SaleContract. |
Status | DealDocumentStatus | Да | DealDocumentEntity.Status (enum строкой) | — | Всегда Uploaded (тип без проверки, рабочий финал). |
Title | string | Да | DealDocumentEntity.Title | — | Отображаемое название на вкладке «Документы». |
FileId | Guid | Да | DealDocumentEntity.FileId | — | Файл в storage/CDN (скачивание — пресайн-ссылка). |
FileName | string | Да | DealDocumentEntity.FileName | — | Оригинальное имя файла. |
ContentType | string | Да | DealDocumentEntity.ContentType | — | MIME (application/pdf). |
SizeBytes | long | Да | DealDocumentEntity.SizeBytes | — | Размер файла. |
RequisiteSetId | Guid? | Нет | DealDocumentEntity.RequisiteSetId | — | Персона/собственник (profile), если задан. |
UploadedByUserId | Guid | Да | DealDocumentEntity.UploadedByUserId | — | Менеджер застройщика — загрузчик. |
UploadedAt | DateTimeOffset | Да | DealDocumentEntity.UploadedAt | — | Момент загрузки. |
CreatedAt | DateTimeOffset | Да | DealDocumentEntity.CreatedAt | — | Создание записи. |
Прежние версии не затираются (INV-DOC-4): полную историю SaleContract отдаёт список документов сделки.
Алгоритм
- Контекст и доступ. Выполняет менеджер застройщика (
User, permissioncrm.deal-documents.manage, иначеCRM_DOCUMENT_UPLOADER_FORBIDDEN); tenant —TenantId = DeveloperCompanyId. Идемпотентность не применяется — загрузка append-only (повтор = новая запись версии, INV-DOC-4). - Проверка входа и предусловий.
DealId/FileId != Guid.Empty;FileName 1..512;ContentType == "application/pdf";SizeBytes ∈ 1..52_428_800;Title 0..256(INV-DOC-3) — иначеVALIDATION_FAILED;- сделка существует в tenant застройщика — иначе
CRM_DEAL_NOT_FOUND(чужой tenant не раскрывается); - допустимость состояния:
Status ∉ {Completed, Cancelled}(нетерминальная, INV-DOC-5) — иначеCRM_DEAL_ALREADY_TERMINAL; - право загрузки (INV-DOC-1): актор — активный участник сделки с ролью
SellManager/PinManager(сторона-владелец типаSaleContract) — иначеCRM_DOCUMENT_UPLOADER_FORBIDDEN.
- Что читаем. Сделку (для guard статуса/tenant) и её активных участников (
RemovedAt is null— проверка роли загрузчика). Файл вcrmне читается — доверяемFileId(валидность бинаря/антивирус — регламент storage). - Бизнес-правила и переходы (INV-DOC-1/3). Создаётся новая запись
SaleContractв статусеUploaded(переход[*] → Uploaded, тип грузит застройщик — без фазыRequestedи безReview), маппинг:TenantId = deal.TenantId,DealId,Type = SaleContract,FileId/FileName/ContentType/SizeBytesиз тела,Title = входной ?? default по Deal.ContractType,RequisiteSetId?,UploadedByUserId = CreatedByUserId = актор,UploadedAt = CreatedAt = now. Инвариант INV-DOC-3 (наличиеFileId/имени/MIME/размера со статусаUploaded) — доменный guard + БД-checkck_deal_documents_file. Фиксируется доменный фактDocumentUploaded; подписчик пишет строку таймлайна сделки (INV-DOC-6). - Что меняется. Появляется новая запись документа
SaleContract(история версий не затирается, INV-DOC-4); в ленту добавляется строка события. Сама сделка не меняется (документ — отдельная таблица). - События. Интеграционное
DocumentStatusChangedEvent→ топикpin.crm.document-status-changed.v1(ContractVersion = 1,AggregateId = DocumentId,AggregateType = "DealDocument"); payload{DocumentId, DealId, TenantId, DocumentType = SaleContract, FromStatus, ToStatus = Uploaded, ChangedByUserId = UploadedByUserId, ChangedAt = UploadedAt}(переход[*] → Uploaded) — через outbox (дом — document-status-changed.md). Потребительnotificationsуведомляет инвестора сделки «договор ДДУ/ДКП готов к скачиванию»; синхронноcrmуведомление не шлёт. - Согласованность. Запись документа, строка таймлайна и событие фиксируются в одном согласованном изменении (атомарно, INV-DOC-6).
- Результат. Проекция созданной записи
DealDocumentModel→ HTTP201. Sensitive-полей в ответе нет (сам файл — за пресайн-ссылкой storage).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | DocumentStatusChangedEvent → pin.crm.document-status-changed.v1 (ToStatus = Uploaded, Type = SaleContract), outbox |
| External calls | none синхронно (файл уже в storage/CDN; валидность бинаря — регламент storage); скачивание клиентом — пресайн-ссылка storage |
| Cache / projections / search | none (deal_documents — прямая запись; агрегат сделки не трогается) |
| Notifications | опосредованно: notifications — консюмер document-status-changed (клиенту «договор готов к скачиванию») |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | FileId/DealId пустые, ContentType вне whitelist, SizeBytes вне 1..50 МБ, длины имён | «Проверьте параметры загрузки» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
CRM_DOCUMENT_UPLOADER_FORBIDDEN | 403 | нет crm.deal-documents.manage, чужой TenantId или актор не SellManager/PinManager сделки (INV-DOC-1) | «Недостаточно прав для загрузки договора» | no retry |
CRM_DEAL_NOT_FOUND | 404 | сделка по DealId не найдена в TenantId | «Сделка не найдена» | no retry |
CRM_DEAL_ALREADY_TERMINAL | 409 | Deal.Status ∈ {Completed, Cancelled} (INV-DOC-5) | «Сделка завершена или отменена» | no retry |
CRM_DOCUMENT_FILE_INVALID | 422 | доменный guard INV-DOC-3 / нарушение ck_deal_documents_file (defense-in-depth) | «Файл договора недопустим» | no retry |
Все коды — no retry: 400/422 требуют исправить входные данные (метаданные файла), 403/404/409 —
доступ или состояние сделки, повтор того же запроса результат не изменит.
Acceptance Criteria
- Given нетерминальная сделка в
TenantIdи актор — активный участникSellManager/PinManager, When загружается валидный PDF, Then создана новая записьSaleContract(Uploaded), добавлена строка таймлайна, событиеDocumentStatusChangedEvent→pin.crm.document-status-changed.v1(ToStatus = Uploaded,Type = SaleContract) в outbox, ответ201. - Given по сделке уже есть запись
SaleContract, When повторная загрузка (например, подписанной застройщиком версии, US-DEV-07), Then прежняя запись не затирается — создаётся новая (INV-DOC-4); полную историю отдаёт список документов сделки. - Given
Deal.Status ∈ {Completed, Cancelled}, When upload, ThenCRM_DEAL_ALREADY_TERMINAL(409) по INV-DOC-5. - Given актор не
SellManager/PinManagerсделки, чужойTenantIdлибо нетcrm.deal-documents.manage, When upload, ThenCRM_DOCUMENT_UPLOADER_FORBIDDEN(403) по INV-DOC-1. - Given сделки по
DealIdнет вTenantId, When upload, ThenCRM_DEAL_NOT_FOUND(404) — существование чужой сделки не раскрывается. - Given
ContentTypeвне whitelistapplication/pdf,SizeBytes ∉ [1, 50 МБ]или пустыеFileId/DealId, When upload, ThenVALIDATION_FAILED(400); нарушение доменного guard /ck_deal_documents_file→CRM_DOCUMENT_FILE_INVALID(422) по INV-DOC-3. - Given успешная загрузка, When событие
document-status-changedдоставлено, Then потребительnotificationsуведомляет инвестора «договор ДДУ/ДКП готов к скачиванию»; синхронноcrmуведомление не шлёт.
Совместимость и наблюдаемость
- Новые optional поля тела/ответа — additive (без breaking); удаление/переименование/смена типа поля или
расширение whitelist
ContentType— breaking (migration + rollout). Enum наружу — строкой (SaleContract,Uploaded); дом значений — deal-document.md. - Logs:
sale-contract uploaded(DealId,DocumentId,TenantId,SizeBytes;FileName— усечённо, без содержимого файла); metrics:crm_sale_contract_upload_total{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.SaleContract, INV-DOC-1/3/4/5/6,ck_deal_documents_file), deal.md (DealStatus,ContractType, участники/роли). - Событие: document-status-changed.md (payload/consumers,
pin.crm.document-status-changed.v1). - Контекст: README; tenancy — ADR-0052.