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

POST /api/developer/crm/deals/{dealId}/complete — Завершить сделку

Менеджер застройщика из карточки сделки (/crm/deals/[dealId]) подтверждает завершение сделки после регистрации в Росреестре: переход Sale → Completed (в UI «продажа (завершена)»). Дом полей сделки — deal.md (метод Complete, INV-DEAL-7/9). Это финальный шаг цепочки застройщика: договоры → эскроу (Ddu) → Росреестр → завершение.

Назначение

По кнопке «Завершить сделку» менеджер застройщика переводит сделку в терминальный статус Completed (проставляется CompletedAt, в ленту пишется DealCompleted). На выходе — обновлённая карточка сделки. Публикуется интеграционное событие deal-completed, по которому смежные контексты доводят продажу до конца: investor-owner заводит объект в портфель инвестора, rewards начисляет комиссию, chessboard переводит объект недвижимости в «Продан».

Metadata

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

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

Доступ
ПроверкаЗначение
Actor typeUser (Менеджер застройщика); Operator (Менеджер ПИН — platform-scope, арбитраж)
Auth policy / Permissionscrm.deals.decide (терминальное решение стороны застройщика, ТЗ.md:391–409)
Scope (RBAC)организация (застройщик): tenant + record-level по сделке
Record-levelактор — SellManager/менеджер застройщика сделки (deal.TenantId) либо Менеджер ПИН
Tenant isolationTenantId = DeveloperCompanyId из claims; сделка читается только в этом tenant (deal.TenantId == actor.TenantId, ADR-0052)
Auditupdate-terminal (UpdatedByUserId = actor.UserId, CompletedAt); бизнес-история — таймлайн DealCompleted; ФИО/эскроу в логи не пишутся (только DealId)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/developer/crm/deals/{dealId}/complete
Success status200 (обновлённая карточка сделки)
Idempotency headerне применяется (естественная идемпотентность: повтор на терминале → CRM_DEAL_ALREADY_TERMINAL, событие — ровно один раз)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = DeveloperCompanyId актораTenant isolation
DealIdrouteGuidДа!= Guid.Empty; существующая сделка в tenant; статус Sale + Росреестр подтверждён (шаг 2)
ActorUserIdidentity claimsGuidДаJWT менеджера застройщика/Менеджера ПИН; пишется в CompletedAt-актора (UpdatedByUserId)

Тело запроса пустое (подтверждение без параметров): все данные события — snapshot полей агрегата на момент завершения. Вложенных сущностей/коллекций нет.

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

Проекция завершённой сделки (дом полей — deal.md; та же модель, что у карточки сделки и смены объекта — ниже только отдаваемая дельта операции).

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаDealEntity.IdИдентификатор сделки.
TitlestringДаDealEntity.TitleЗаголовок карточки.
Statusstring (enum DealStatus)ДаDealEntity.StatusCompleted — «продажа (завершена)».
StepIdGuidДаDealEntity.StepIdТекущий этап канбана (завершением не меняется).
PropertyObjectIdGuidДаDealEntity.PropertyObjectIdОбъект сделки (→ «Продан» в chessboard).
ProjectIdGuidДаDealEntity.ProjectIdПроект объекта (реплика chessboard).
PriceMoneyModelДаDealEntity.PriceФинальная стоимость ({Amount, Currency}).
ContractTypestring (enum DealContractType)ДаDealEntity.ContractTypeDdu/Dkp (обязателен с группы Sale, INV-DEAL-6).
CompletedAtDateTimeOffsetДаDealEntity.CompletedAtМомент подтверждения завершения.
UpdatedAtDateTimeOffsetДаDealEntity.UpdatedAtUS-INV-04.

Списки сделок застройщика — отдельные операции; здесь ответ скалярный (одна карточка).

Алгоритм

  1. Контекст и доступ. Выполняет менеджер застройщика (JWT) либо менеджер ПИН (platform-scope) с permission crm.deals.decide; tenant — TenantId = DeveloperCompanyId. Естественная идемпотентность: повтор на терминале отклоняется (шаг 2), событие публикуется ровно один раз.
  2. Проверка входа и предусловий.
    • DealId != Guid.Empty — иначе VALIDATION_FAILED;
    • сделка существует в tenant застройщика — иначе CRM_DEAL_NOT_FOUND (чужой tenant не раскрывается: 404);
    • доступ: актор — менеджер застройщика этого tenant (record-level) либо менеджер ПИН — иначе CRM_DEAL_FORBIDDEN;
    • терминальность (INV-DEAL-9): Status ∈ {Completed, Cancelled}CRM_DEAL_ALREADY_TERMINAL (терминал иммутабелен);
    • порядок стадий (INV-DEAL-7): Status == Sale и RosreestrRegisteredAt != null (регистрация в Росреестре подтверждена) — иначе CRM_DEAL_STAGE_ORDER_VIOLATION (для Application/Booking продажа ещё не начата).
  3. Что читаем. Сделку — для перехода; гонку с move-step/cancel/сменой объекта разрешает версия строки. Внешних вызовов нет: все поля события (PrimaryInvestorUserId, CommissionRecipientUserId = ExecutorAgentUserId на момент завершения, OriginAgentUserId, OriginAgencyCompanyId, PropertyObjectId, ProjectId, Price, ContractType) — снимок на агрегате с момента создания/перехода в Sale.
  4. Бизнес-правила и переходы (INV-DEAL-7/9). Завершение проверяет guard: Status == Sale и RosreestrRegisteredAt != null (иначе CRM_DEAL_STAGE_ORDER_VIOLATION), сделка не терминальна (иначе CRM_DEAL_ALREADY_TERMINAL); переход Sale → Completed; маппинг: Status = Completed, CompletedAt = now, UpdatedAt = now, UpdatedByUserId = ActorUserId. Фиксируется доменный факт DealStatusChanged (From: Sale, To: Completed); в той же транзакции материализуется запись ленты (INV-TLE-2): DealTimelineEventEntity(Type = DealCompleted, Status = Done, OccurredAt = now, CompletedAt = now, ActorRole = SellManager, Payload = TimelineEmptyPayload) — дом типов/payload deal-timeline-event.md.
  5. Что меняется. Сделка переходит в Completed (+CompletedAt), в ленту добавляется append-only строка DealCompleted (INV-TLE-1). Конкурентный доступ — по версии строки: параллельный Cancel/MoveToStep не должен затирать завершение → при гонке ретрай, терминальный статус даёт CRM_DEAL_ALREADY_TERMINAL.
  6. События. Интеграционное DealCompletedEvent → топик pin.crm.deal-completed.v1 (ContractVersion = 1, ключ = DealId); payload — снимок агрегата: DealId, TenantId, PrimaryInvestorUserId, CommissionRecipientUserId (= ExecutorAgentUserId на момент завершения — получатель комиссии), OriginAgentUserId, OriginAgencyCompanyId (иммутабельная атрибуция — основание начислений), PropertyObjectId, ProjectId, FinalPrice {Amount, Currency}, ContractType, CompletedAt (дом — deal-completed.md). Публикуется ровно один раз (Completed терминален, INV-DEAL-9). Потребители: investor-owner (объект в портфель), rewards (комиссия), chessboard (объект → «Продан»).
  7. Согласованность. Обновление сделки, строка таймлайна и событие фиксируются в одном согласованном изменении (атомарно).
  8. Результат. Карточка DealCardModel (Status = Completed, CompletedAt, Price, ContractType, UpdatedAt) → HTTP 200.

Диаграмма

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

ТипДетали
Integration eventsDealCompletedEventpin.crm.deal-completed.v1 (ровно один на сделку), outbox
External callsnone (все поля события — snapshot на агрегате; статус объекта недвижимости меняет chessboard как консюмер)
Cache / projections / searchdeals.Status → Completed (ix_deals_status); лента таймлайна получает строку DealCompleted; объект недвижимости PropertyObjectId в chessboard → «Продан», объект в портфель investor-owner, комиссия rewards — всё через консюмеров события
Notificationsопосредованно: notifications — консюмер deal-completed (уведомление сторонам о завершении) и TimelineItemAdded (US-INV-11)

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400формат DealId«Проверьте корректность запроса»no retry
CRM_DEAL_NOT_FOUND404сделка не найдена в tenant застройщика«Сделка не найдена»no retry
CRM_DEAL_FORBIDDEN403сделка чужого застройщика / нет прав«Нет доступа к сделке»no retry
CRM_DEAL_ALREADY_TERMINAL409Status ∈ {Completed, Cancelled} (INV-DEAL-9)«Сделка уже завершена или отменена»no retry
CRM_DEAL_STAGE_ORDER_VIOLATION409Status != Sale либо RosreestrRegisteredAt == null (INV-DEAL-7: Росреестр не подтверждён)«Завершение недоступно: не пройдены предыдущие этапы»no retry

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

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

  • актор — менеджер застройщика сделки (SellManager) либо Менеджер ПИН с permission crm.deals.decide;
  • сделка существует в TenantId = DeveloperCompanyId актора и не терминальна (INV-DEAL-9);
  • пройдены предыдущие стадии (INV-DEAL-7): Status == Sale и RosreestrRegisteredAt != null (регистрация в Росреестре подтверждена).

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

  • сделка переведена Sale → Completed, проставлен CompletedAt, UpdatedByUserId/UpdatedAt;
  • в ленту добавлена append-only строка DealCompleted (INV-TLE-1);
  • Completed терминален и иммутабелен (INV-DEAL-9) — событие deal-completed.v1 уходит в outbox ровно один раз; StepId завершением не меняется.

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

DealCompletedEvent → pin.crm.deal-completed.v1 (ключ = DealId, снимок агрегата: PrimaryInvestorUserId, CommissionRecipientUserId (= ExecutorAgentUserId на момент завершения), OriginAgentUserId, OriginAgencyCompanyId, PropertyObjectId, ProjectId, FinalPrice, ContractType, CompletedAt) обрабатывается асинхронно после коммита. В ответе 200 актор видит Completed сразу, но эффекты соседей доходят с задержкой (окно eventual-consistency — секунды):

  1. investor-owner — заводит объект недвижимости в портфель инвестора;
  2. rewards — начисляет комиссию по завершённой сделке;
  3. chessboard — переводит объект недвижимости в статус «Продан»;
  4. notifications — уведомляет стороны о завершении.

Что этот сервис предоставляет: deal-completed.v1 — additive-only, exactly-once по терминальному переходу. Что ожидает: ничего синхронного — все поля события уже на агрегате (внешних вызовов на пути нет). В промежуточном окне объект в шахматке ещё «Бронь»/«Продажа», портфель инвестора ещё не пополнен, комиссия не начислена — это ожидаемое eventual-consistency состояние, а не ошибка.

Acceptance Criteria

  • Happy path. Дано: сделка Sale с подтверждённым Росреестром. Когда: менеджер завершает. Тогда: 200, Status = Completed, CompletedAt проставлен, строка ленты DealCompleted, deal-completed.v1 опубликовано один раз.
  • Стадия не Sale. Дано: сделка в Application/Booking. Когда: завершение. Тогда: 409 CRM_DEAL_STAGE_ORDER_VIOLATION, событий нет.
  • Росреестр не подтверждён. Дано: Status == Sale, но RosreestrRegisteredAt == null. Когда: завершение. Тогда: 409 CRM_DEAL_STAGE_ORDER_VIOLATION (INV-DEAL-7).
  • Уже терминальна / повтор. Дано: Status ∈ {Completed, Cancelled}. Когда: завершение. Тогда: 409 CRM_DEAL_ALREADY_TERMINAL; событие повторно не публикуется.
  • Гонка с отменой. Дано: параллельный Cancel/MoveToStep. Когда: конфликт версии строки. Тогда: проигравший ретраится; на терминале получает CRM_DEAL_ALREADY_TERMINAL (завершение не затирается отменой).
  • Чужой застройщик. Дано: сделка другого tenant. Когда: завершение. Тогда: 404 CRM_DEAL_NOT_FOUND.

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

  • Тело запроса пустое; ответ DealCardModel — новые optional поля additive без breaking; удаление/ переименование/смена типа поля или Success status — breaking (migration + rollout). Unknown enum DealStatus/DealContractType в ответе не возникают (сервер — источник). Контракт события — additive-only (deal-completed.md).
  • Logs: deal completed (DealId, TenantId, PropertyObjectId; сумма/ФИО/эскроу — не пишутся); metrics crm_deals_completed_total{contract_type}, latency p95 < 500 мс; traces — сквозной trace через traceparent; dashboard Grafana pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

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

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

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