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

POST /api/developer/crm/fixations/{fixationId}/reject — Отклонить фиксацию

Менеджер застройщика отклоняет поданную заявку на фиксацию клиента. Переход Pending → Rejected (INV-FIX-3), причина отклонения обязательна (INV-FIX-5). Отклонение не даёт агенту авторизацию вести клиента по объектам застройщика и права на комиссию не возникает (в отличие от одобрения, которое такую авторизацию выдаёт, но сделку тоже не создаёт — INV-FIX-6); событие fixation-rejected уведомляет агента-инициатора. Дом полей заявки, статусы и инварианты — в fixation-request.md.

Назначение

Позволяет стороне застройщика (владелец решения, ADR-0052) отклонить заявку на фиксацию с обязательной причиной: клиент уже в базе на других условиях, некорректные данные, конфликт закрепления и т.п. Отклонение возможно только из Pending и только менеджером компании-адресата (TenantId = DeveloperCompanyId) или менеджером ПИН. На выходе — обновлённая карточка фиксации. После отклонения освобождается unique-тройка ux_fixation_active_triple — по той же паре агент↔клиент↔застройщик можно подать новую заявку.

Metadata

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

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

ПроверкаЗначение
Actor typeUser (Менеджер застройщика); Operator (Менеджер ПИН — platform-scope, арбитраж)
Auth policy / Permissionscrm.fixations.decide (сторона-адресат решения)
Scope (RBAC)организация (застройщик): TenantId = DeveloperCompanyId
Record-levelзаявка читается только в своём tenant (fixation.TenantId == actor.TenantId); Менеджер ПИН — обход tenant-scope
Tenant isolationTenantId = DeveloperCompanyId из claims — застройщик решает по своим заявкам (ADR-0052); чужой tenant не раскрывается = 404
Auditupdate: переход Pending → Rejected, поля DecidedByUserId/DecidedAt/RejectReason, инкремент Version; RejectReason — free-text, в логи не пишется
Доступ

Решение принимает менеджер застройщика — владелец заявки по tenant (TenantId = DeveloperCompanyId, permission crm.fixations.decide), либо Менеджер ПИН в режиме арбитража (platform-scope). Заявка чужого tenant не раскрывается: обращение к ней даёт 404 CRM_FIXATION_NOT_FOUND, а не 403; отсутствие permission у менеджера своего tenant — 403 (ADR-0052).

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/developer/crm/fixations/{fixationId}/reject
Success status200 (возвращается обновлённая карточка FixationCardModel)
Idempotency headerне применяется — повтор ловится проверкой состояния (шаг 4): вторая попытка над уже Rejected заявкой → CRM_FIXATION_INVALID_STATE
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = DeveloperCompanyId актораTenant isolation
FixationIdrouteGuidДа!= Guid.Empty; существующая заявка Pending в tenant застройщика (шаги 2–4)
RejectReasonbodystringДа1..1000, не пусто после trim (INV-FIX-5, ck_fixation_reject_reason); иначе CRM_FIXATION_REASON_REQUIREDfree-text менеджера — в логи не пишется (может содержать PII)
ActorUserIdidentity claimsGuidДаJWT менеджера застройщика; пишется в DecidedByUserId

Тело — named-модель RejectFixationCommand (единственное поле RejectReason); вложенных сущностей и коллекций нет.

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

Дом проекции — get-fixation.md (та же карточка фиксации); поля агрегата — fixation-request.md. Наружу отдаётся обновлённое состояние; дельта после отклонения: Status = Rejected (enum строкой), RejectReason заполнено, DecidedByUserId/DecidedAt проставлены. Ссылки на сделку у фиксации нет (сделка не создаётся ни при отклонении, ни при одобрении). Остальные поля — 1:1 из агрегата.

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

Предусловия (должны быть истинны до вызова):

  • заявка на фиксацию существует в TenantId актора и не soft-deleted;
  • заявка в статусе Pending (решение ещё не принято, INV-FIX-3);
  • актор — менеджер компании-адресата с crm.fixations.decide (или менеджер ПИН, platform-scope);
  • причина отклонения будет передана непустой (INV-FIX-5).

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

  • переход Pending → Rejected; проставлены RejectReason/DecidedByUserId/DecidedAt; версия инкрементирована; авторизация не выдаётся, сделка не создаётся, право на комиссию не возникает;
  • освобождена активная unique-тройка ux_fixation_active_triple — по той же паре агент↔клиент↔застройщик можно подать новую заявку;
  • опубликовано FixationRejectedEventpin.crm.fixation-rejected.v1; поисковая проекция заявки переведена в IsActive = false (выпадает из активных выборок и дубль-детекта новых заявок).

Постусловия при отказе: заявка не изменилась (статус, причина, версия — прежние), событие не публиковалось, тройка не освобождалась.

Алгоритм

  1. Контекст и доступ. Выполняет менеджер застройщика (User, permission crm.fixations.decide) либо менеджер ПИН (platform-scope); tenant — TenantId = DeveloperCompanyId. Идемпотентность не требуется — повтор ловится проверкой состояния (шаг 4).
  2. Проверка входа и предусловий.
    • FixationId != Guid.Empty; RejectReason не null, после trim не пусто, длина 1..1000 — иначе VALIDATION_FAILED; отдельно пустая причина → CRM_FIXATION_REASON_REQUIRED (INV-FIX-5, ck_fixation_reject_reason);
    • заявка существует в tenant застройщика и не soft-deleted — иначе CRM_FIXATION_NOT_FOUND (чужие заявки не раскрываются: 404);
    • состояние допускает отклонение: Status == Pending (INV-FIX-3) — иначе CRM_FIXATION_INVALID_STATE (уже решена/отменена);
    • scope: заявка своего tenant (застройщик — сторона-владелец решения; отдельная record-level проверка не нужна).
  3. Что читаем. Заявку в tenant застройщика (одиночная строка; гонку разрешает версия строки на шаге 5). Для проекции ответа: если у заявки нет override пожеланий (WishesOverride == null) — читаем лид для актуальных Lead.Wishes; иначе лид не нужен. Клиент/сделка не читаются (отклонение их не затрагивает).
  4. Бизнес-правила и переходы. Отклонение (INV-FIX-3/5): guard Status == Pending (иначе CRM_FIXATION_INVALID_STATE), reason непуст (иначе CRM_FIXATION_REASON_REQUIRED); переход Pending → Rejected; маппинг: Status = Rejected, RejectReason = trim(RejectReason), DecidedByUserId = ActorUserId, DecidedAt = now, UpdatedByUserId = ActorUserId, UpdatedAt = now, Version += 1. Поля дубль-детекта (Uniqueness/DuplicateOfFixationId) не меняются; сделка не создаётся. Фиксируется доменный факт FixationDecided (Status = Rejected). Проекция ответа FixationCardModel: эффективные Wishes = WishesOverride ?? Lead.Wishes, остальное 1:1.
  5. Что меняется. Заявка переходит в Rejected с причиной; освобождается unique-тройка ux_fixation_active_triple — по той же паре агент↔клиент↔застройщик можно подать новую заявку. Конкурентный доступ — по версии строки: если агент параллельно отозвал заявку (Pending → Cancelled) между чтением и записью → CRM_FIXATION_CONFLICT, менеджер перечитывает заявку. Лид/клиент не мутируются.
  6. События. Интеграционное FixationRejectedEvent → топик pin.crm.fixation-rejected.v1 (ContractVersion = 1); payload из агрегата: FixationRequestId, TenantId, OriginAgencyCompanyId, OriginAgentUserId, ExecutorAgentUserId, InvestorUserId, RejectedByUserId, Reason, RejectedAt (дом — fixation-rejected.md). При отклонении fixation-approved не публикуется (авторизация не выдаётся); сделка не создаётся ни при отклонении, ни при одобрении.
  7. Согласованность. Переход статуса, простановка DecidedBy/At/RejectReason и запись события фиксируются в одном согласованном изменении (атомарно).
  8. Результат. Карточка FixationCardModel (Status = Rejected, RejectReason, DecidedAt) → HTTP 200; ошибки — 400/404/409 (таблица ниже).

Диаграмма

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

ТипДетали
Integration eventsFixationRejectedEventpin.crm.fixation-rejected.v1 (одно событие), outbox
External callsnone (сделка не создаётся; chessboard/rewards синхронно не вызываются)
Cache / projections / searchпроекция фиксаций (FixationRequestSearchDocument) обновляется (Status = RejectedIsActive = false) — заявка выпадает из активных выборок и дубль-детекта новых заявок; ФИО/телефон snapshot иммутабельны; освобождается ux_fixation_active_triple
Notificationsопосредованно: notifications — консюмер fixation-rejected (агенту «Заявка на фиксацию отклонена» с причиной)

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

Eventual consistency

Менеджеру ответ 200 приходит сразу после атомарного перехода в Rejected, но уведомление агента-инициатора — асинхронное, после доставки fixation-rejected.v1 потребителю notifications (окно = задержка outbox → консюмер).

  1. Синхронно (в одном изменении): заявка переведена в Rejected, событие положено в outbox — ответ 200 менеджеру. В экране «Фиксации» застройщика строка сразу видна как Rejected.
  2. Асинхронно: fixation-rejected.v1 доставляется потребителю notifications, который уведомляет агента-инициатора «Заявка отклонена» с причиной. Порядок по заявке сохраняется ключом партиционирования FixationRequestId; повторная доставка обрабатывается идемпотентно.
  3. Окно eventual-consistency = задержка outbox → консюмер: до её завершения агент в своей проекции ещё видит заявку как Pending; после обработки события — как Rejected. rewards/chessboard синхронно не вызываются (сделка не создаётся; при одобрении rewards лишь фиксирует право/eligibility).

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400FixationId = Guid.Empty / формат тела«Проверьте корректность запроса»no retry
CRM_FIXATION_REASON_REQUIRED400RejectReason пуст/пробелы (INV-FIX-5, ck_fixation_reject_reason)«Укажите причину отклонения»no retry
CRM_FIXATION_NOT_FOUND404заявки нет в tenant застройщика (или soft-deleted)«Заявка на фиксацию не найдена»no retry
CRM_FIXATION_INVALID_STATE409Status != Pending (уже решена/отменена, INV-FIX-3)«Заявку нельзя отклонить в текущем статусе»no retry
CRM_FIXATION_CONFLICT409RowVersion-гонка: агент отозвал заявку параллельно (шаг 7)«Заявка изменена, обновите данные»retry (перечитать состояние)

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

  • Контракт минимальный (route + RejectReasonFixationCardModel); новые optional поля карточки — additive; смена маршрута/семантики статуса или удаление/переименование поля — breaking (migration + rollout). Enum Status сериализуется строкой — клиент обязан толерантно обрабатывать неизвестное значение. Контракт события — additive-only (fixation-rejected.md).
  • Logs: fixation-reject requested/rejected/conflict (FixationId, TenantId, OriginAgentUserId, ActorUserId; RejectReason и ФИО/телефон в логи не пишутся); metrics: crm_fixation_reject_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

Acceptance criteria

  • Given заявка в Pending и непустая причина, When менеджер застройщика отклоняет её, Then 200 с FixationCardModel (Status = Rejected, RejectReason, DecidedAt), опубликовано fixation-rejected.v1, тройка ux_fixation_active_triple освобождена.
  • Given RejectReason пустой/из пробелов, When отклонение, Then 400 CRM_FIXATION_REASON_REQUIRED (INV-FIX-5); состояние заявки не изменилось.
  • Given заявка не в Pending (уже решена/отменена), When отклонение, Then 409 CRM_FIXATION_INVALID_STATE (в т.ч. как отлов повторного вызова).
  • Given агент параллельно отозвал заявку (Pending → Cancelled) между чтением и записью, When отклонение коммитит, Then 409 CRM_FIXATION_CONFLICT (перечитать состояние).
  • Given заявка чужого TenantId/несуществующая/soft-deleted, When отклонение, Then 404 CRM_FIXATION_NOT_FOUND (чужой tenant не раскрывается).
  • Given заявка отклонена, When по той же паре агент↔клиент↔застройщик подаётся новая заявка, Then она допускается (активная тройка свободна).
  • Given актор без crm.fixations.decide, When отклонение, Then 403 (нет прав); без JWT — 401.

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

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

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