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

POST /api/developer/crm/deals/{dealId}/documents/{documentId}/confirm — Подтвердить документы ДДУ/ДКП

Менеджер застройщика принимает или отклоняет загруженный клиентом документ для формирования договора ДДУ/ДКП (US-INV-14). Документ переходит UnderReview → Accepted | Rejected (INV-DOC-2). Операция пишет таймлайн-событие сделки и публикует DocumentStatusChangedEvent. Дом полей — deal-document.md.

Назначение

Даёт менеджеру застройщика по документу вкладки «Документы» карточки сделки принять решение проверки: принять (клиент может двигаться дальше по договору) либо отклонить с причиной (клиент видит причину и перезагружает документ новой записью, INV-DOC-4). На выходе — обновлённый документ. Область — документы, проверяемые застройщиком (в первую очередь ContractDraftDocument для ДДУ/ДКП; механизм общий для reviewable-типов, где получатель — застройщик).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areadeveloper
Feature groupdeal-documents
Статусdraft
Документdocs/06-services/crm/api/developer/confirm-contract-documents.md

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

ПроверкаЗначение
Actor typeUser (Менеджер застройщика)
Auth policy / Permissionscrm.deal-documents.review (JWT identity)
Scope (RBAC)организация (застройщик) — TenantId = DeveloperCompanyId
Record-levelактор — сторона-проверяющий типа (INV-DOC-2): загрузивший клиент/агент (investor-area) отклоняется
Tenant isolationTenantId в каждом запросе (= DeveloperCompanyId)
Auditupdate (решение проверки — иммутабельный факт): RejectReason в логи целиком не пишется
Доступ

Решение принимает менеджер застройщика — сторона-проверяющий типа документа (INV-DOC-2). Для типов, где застройщик сам выступает загрузчиком (BookingContract/SaleContract/RosreestrDocument), проверка запрещена — CRM_DOCUMENT_REVIEWER_FORBIDDEN. Tenant — TenantId = DeveloperCompanyId в каждом запросе.

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/developer/crm/deals/{dealId}/documents/{documentId}/confirm
Success status200 (решение применено, возвращён обновлённый документ)
Idempotency headerне применяется (повтор на терминальном статусе → CRM_DOCUMENT_ALREADY_REVIEWED, шаг 2)
Correlationсквозной trace через заголовок traceparent
Идемпотентность

Отдельный idempotency-заголовок не требуется: повторный вызов на документе, уже переведённом в терминальный статус, безопасно завершается 409 CRM_DOCUMENT_ALREADY_REVIEWED (шаг 2 алгоритма) — без побочных эффектов.

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired (DeveloperCompanyId)Tenant isolation
DealIdrouteGuidДа!= Guid.Empty; существующая сделка (шаг 2)
DocumentIdrouteGuidДа!= Guid.Empty; документ существует и DealId совпадает (шаг 2)
DecisionbodyDocumentReviewDecisionДаenum строкой Accept/Reject; unknown → ValidationError
RejectReasonbodystring?Условнообязателен и 1..1000 при Decision = Reject; при Accept — пусто (INV-DOC-2)свободный текст: в логи не целиком

Decision — строгий enum DocumentReviewDecision { Accept, Reject } (не bool/строка), маппится в переход Review(...). ActorUserId — из JWT (не из тела), пишется в ReviewedByUserId.

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

Проекция агрегата DealDocumentEntity («один дом» — сущность; здесь только возвращаемая дельта):

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаDealDocumentEntity.IdИдентификатор документа.
DealIdGuidДаDealDocumentEntity.DealIdСделка (deal.md).
TypeDealDocumentTypeДаDealDocumentEntity.Type (enum строкой)Тип документа (напр. ContractDraftDocument).
StatusDealDocumentStatusДаDealDocumentEntity.Status (enum строкой)Accepted / Rejected после решения.
TitlestringДаDealDocumentEntity.TitleОтображаемое название.
RejectReasonstring?УсловноDealDocumentEntity.RejectReasonПричина при Rejected; null при Accepted (US-INV-14).
ReviewedByUserIdGuidДаDealDocumentEntity.ReviewedByUserIdКто принял решение (= актор).
ReviewedAtDateTimeOffsetДаDealDocumentEntity.ReviewedAtМомент решения.
VersionlongДаDealDocumentEntity.VersionДля оптимистичной блокировки клиента.

FileId/FileName/ContentType/SizeBytes в ответ не входят (скачивание — отдельным методом по пресайн-ссылке storage). Все enum — строкой; дом значений — deal-document.md.

Алгоритм

  1. Контекст и доступ. Мутирует менеджер застройщика (User, permission crm.deal-documents.review, иначе CRM_DOCUMENTS_FORBIDDEN); tenant — TenantId = DeveloperCompanyId. Идемпотентность не требуется — повтор на терминальном статусе → CRM_DOCUMENT_ALREADY_REVIEWED (шаг 2).
  2. Проверка входа и предусловий.
    • DealId/DocumentId != Guid.Empty; Decision — известное значение enum (Accept/Reject); при Decision = RejectRejectReason не пуст и 1..1000, при AcceptRejectReason пуст — иначе VALIDATION_FAILED / CRM_DOCUMENT_REASON_REQUIRED (INV-DOC-2);
    • сделка существует в tenant застройщика — иначе CRM_DEAL_NOT_FOUND (чужой tenant не раскрывается);
    • документ существует и его DealId совпадает — иначе CRM_DOCUMENT_NOT_FOUND;
    • состояние допускает проверку: document.Status ∈ {Uploaded, UnderReview} — иначе Requested (файла нет) → CRM_DOCUMENT_INVALID_STATUS; Accepted/Rejected (терминал) → CRM_DOCUMENT_ALREADY_REVIEWED;
    • доступ: тип документа — reviewable застройщиком (получатель = менеджер застройщика); тип, где застройщик — загрузчик (BookingContract/SaleContract/RosreestrDocument) → CRM_DOCUMENT_REVIEWER_FORBIDDEN (INV-DOC-2).
  3. Что читаем. Документ (мутируемый агрегат) под блокировкой строки — от гонки двойного решения; сделку-родителя — read, для guard tenant/статуса.
  4. Бизнес-правила и переходы. Решение проверки (INV-DOC-2: guard Status ∈ {Uploaded, UnderReview} и «актор — сторона-проверяющий»): переход → Accepted (при Accept: RejectReason = null) либо → Rejected (при Reject: RejectReason задан, иначе CRM_DOCUMENT_REASON_REQUIRED). Маппинг поле-в-поле: Status = decision, ReviewedByUserId = ActorUserId, ReviewedAt = now, RejectReason по решению, UpdatedByUserId = ActorUserId, Version += 1. Фиксируется доменный факт DocumentReviewed; подписчик материализует таймлайн DocumentAccepted или DocumentRejected (deal-timeline-event.md, INV-TLE-2).
  5. Что меняется. Документ переходит в терминальный статус (Accepted/Rejected), в ленту сделки добавляется строка DocumentAccepted/DocumentRejected. Конкурентный доступ — по версии строки: рассинхрон при записи → CRM_CONCURRENCY_CONFLICT (перечитать).
  6. События. Интеграционное DocumentStatusChangedEvent → топик pin.crm.document-status-changed.v1 (ContractVersion = 1); payload {DocumentId, DealId, TenantId, DocumentType = Type, FromStatus (прежний), ToStatus = decision, RejectReason?, ChangedByUserId = ActorUserId, ChangedAt = ReviewedAt} (дом — document-status-changed.md). Потребитель notifications — статус документа инвестору (US-INV-14.3, US-INV-11).
  7. Согласованность. Обновление документа, строка таймлайна и событие фиксируются в одном согласованном изменении (атомарно, INV-DOC-6).
  8. Результат. DealDocumentModel (Id, DealId, Type, Status, Title, RejectReason, ReviewedByUserId, ReviewedAt, Version) → HTTP 200.

Диаграмма

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

ТипДетали
Integration eventsDocumentStatusChangedEventpin.crm.document-status-changed.v1 (FromStatus → Accepted/Rejected), outbox
External callsnone (данные — своя схема crm; бинарь файла — storage, здесь не читается)
Cache / projections / searchтаймлайн-строка DocumentAccepted/DocumentRejected (проекция карточки сделки)
Notificationsопосредованно: notifications — консюмер document-status-changed (статус документа инвестору, US-INV-14.3)

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400неизвестный Decision, Guid.Empty в route«Проверьте параметры запроса»no retry
CRM_DOCUMENT_REASON_REQUIRED400Decision = Reject без RejectReason (INV-DOC-2)«Укажите причину отклонения»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»no retry
CRM_DOCUMENTS_FORBIDDEN403нет permission crm.deal-documents.review«Недостаточно прав»no retry
CRM_DOCUMENT_REVIEWER_FORBIDDEN403актор не сторона-проверяющий типа (INV-DOC-2)«Недостаточно прав для проверки документа»no retry
CRM_DEAL_NOT_FOUND404сделка не найдена / чужой tenant«Сделка не найдена»no retry
CRM_DOCUMENT_NOT_FOUND404документ не найден или DealId не совпадает«Документ не найден»no retry
CRM_DOCUMENT_INVALID_STATUS409статус не Uploaded/UnderReview (напр. Requested — файла нет)«Документ не готов к проверке»no retry
CRM_DOCUMENT_ALREADY_REVIEWED409документ уже Accepted/Rejected (терминал)«Решение по документу уже принято»no retry
CRM_CONCURRENCY_CONFLICT409рассинхрон RowVersion (xmin) при Update«Документ изменён, обновите»retry (перечитка)

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

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

  • актор — менеджер застройщика с permission crm.deal-documents.review;
  • сделка существует в TenantId = DeveloperCompanyId актора; документ существует, его DealId совпадает;
  • тип документа — reviewable застройщиком (получатель = менеджер застройщика; типы, где застройщик загрузчик, — BookingContract/SaleContract/RosreestrDocument — отклоняются, INV-DOC-2);
  • документ в статусе Uploaded/UnderReview (не Requested без файла, не терминальный);
  • при Decision = Reject задан непустой RejectReason (1..1000).

Постусловия (гарантированы после успеха):

  • документ переведён в терминальный статус Accepted (при Accept, RejectReason = null) либо Rejected (при Reject, RejectReason задан) с ReviewedByUserId/ReviewedAt, Version +1;
  • в ленту сделки добавлена строка DocumentAccepted/DocumentRejected (INV-TLE-2); в outbox — document-status-changed.v1;
  • при Rejected инвестор перезагружает документ новой записью (INV-DOC-4) — прежняя остаётся терминальной.

Интеграционная последовательность (после коммита)

DocumentStatusChangedEvent → pin.crm.document-status-changed.v1 (FromStatus → Accepted/Rejected, RejectReason?) обрабатывается асинхронно; notifications доводит статус документа инвестору (US-INV-14.3, US-INV-11). Инвестор видит решение с задержкой (окно eventual-consistency — секунды). Что предоставляет: событие — additive-only, ключ-эффект идемпотентен по DocumentId; при Rejected payload несёт причину для отображения инвестору.

Acceptance Criteria

  • Принятие. Дано: документ Uploaded/UnderReview reviewable-типа. Когда: Decision = Accept. Тогда: 200, Status = Accepted, RejectReason = null, строка ленты DocumentAccepted, событие Accepted.
  • Отклонение с причиной. Дано: тот же документ. Когда: Decision = Reject + RejectReason. Тогда: 200, Status = Rejected, причина сохранена, строка ленты DocumentRejected, событие Rejected.
  • Отклонение без причины. Дано: Decision = Reject, RejectReason пуст. Когда: решение. Тогда: 400 CRM_DOCUMENT_REASON_REQUIRED (INV-DOC-2).
  • Уже проверен / повтор. Дано: документ уже Accepted/Rejected. Когда: повтор. Тогда: 409 CRM_DOCUMENT_ALREADY_REVIEWED.
  • Файла нет. Дано: документ в статусе Requested (файл не загружен). Когда: решение. Тогда: 409 CRM_DOCUMENT_INVALID_STATUS.
  • Чужой проверяющий. Дано: тип, где застройщик — загрузчик (RosreestrDocument и т.п.). Когда: решение. Тогда: 403 CRM_DOCUMENT_REVIEWER_FORBIDDEN (INV-DOC-2).
  • Двойное решение (гонка). Дано: два параллельных решения по документу. Когда: конфликт версии строки. Тогда: 409 CRM_CONCURRENCY_CONFLICT; проигравший перечитывает и видит терминальный статус.

Совместимость и наблюдаемость

  • Новые optional-поля ответа — additive (без breaking); удаление/переименование/смена типа поля или значения enum DocumentReviewDecision/DealDocumentStatus — breaking (migration + rollout). Unknown Decision во входе — ValidationError (не «тихий» no-op).
  • Logs: deal document reviewed (DocumentId, DealId, TenantId, ToStatus; RejectReason — маской reason=***); metrics: crm_deal_document_review_total{decision,result}, latency p95 (< 500 мс, SLO команд); traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь).

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

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

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