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

PUT /api/agent/crm/leads/{leadId}/wishes — Обновить хотелки лида

Редактирование «шапки хотелок» инвестора (LeadWishes, owned VO) в карточке лида агента: бюджет, желаемый город, срочность, цель покупки, предпочтительный способ оплаты, комментарий. Лид — источник истины по хотелкам; заявка на фиксацию хранит собственный nullable snapshot FixationWishesOverride и при этом изменении не трогается (fixation-request.md).

Назначение

Дать агенту-владельцу (или Менеджеру ПИН) обновить типизированную шапку хотелок активного лида после разговора с инвестором. Полная замена owned-VO LeadWishes в агрегате LeadEntity; статус лида и лента событий не меняются, интеграционные события не публикуются.

Полная замена, а не патч

PUT заменяет owned-VO LeadWishes целиком: optional-поле, не переданное в теле, становится null (сбрасывается, включая пару CityId/CityName) — кроме BudgetCurrencyId, для которого отсутствие значения означает валюту RUB справочника (ADR-0048).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areaagent (JWT агента/руководителя агентства/Менеджера ПИН)
Feature groupleads
Статусdraft
Документdocs/06-services/crm/api/agent/update-lead-wishes.md

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

ПроверкаЗначение
Actor typeUser (Агент-владелец лида) / User (Менеджер ПИН, platform-scope)
Auth policy / Permissionscrm.leads.manage
Scope (RBAC)организация (агентство) — record-level по владельцу; Менеджер ПИН — платформа
Record-levelINV-LEAD-5: lead.ExecutorAgentUserId == ActorUserId (агент-исполнитель) или Менеджер ПИН; иначе CRM_LEAD_FORBIDDEN
Tenant isolationTenantId = AgencyCompanyId в каждом запросе; lead.TenantId обязан совпасть
Auditupdate (UpdatedByUserId, Version); лента событий не пишется
Доступ

Permission crm.leads.manage действует в пределах своего TenantId (агентство). Внутри tenant record-level (INV-LEAD-5) сужает круг: агент правит хотелки только у лидов, где он — исполнитель (ExecutorAgentUserId = actor); Менеджер ПИН — любой лид на платформе.

HTTP-контракт

ПолеЗначение
MethodPUT
Route/api/agent/crm/leads/{leadId}/wishes
Success status200 (обновлено, возвращается карточка лида)
Idempotency headerне применяется — PUT семантически идемпотентен (полная замена шапки); конкурентность — RowVersion
Correlationсквозной trace через заголовок traceparent

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

Поля тела 1:1 отображаются в owned-VO LeadWishes (дом полей и ограничения — lead.md, раздел «LeadWishes»); enum-ы — строкой. Здесь — только источник и валидация запроса.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = AgencyCompanyIdTenant isolation
ActorUserIdidentity claimsGuidДаRequiredRecord-level (владелец)
LeadIdrouteGuidДа!= Guid.Empty; существующий лид
BudgetbodyNullableRange<decimal>Нет{Min, Max}; каждая граница >= 0; Min <= Max
BudgetCurrencyIdbodyshort?Нетссылка на справочник валют handbook; не задан → валюта RUB; неизвестная/архивная запись → VALIDATION_FAILED (резолв, шаг 3); RUB-only (ADR-0048)
CityIdbodylong?Нетссылка на справочник городов handbook; неизвестный/архивный город → CRM_CITY_UNKNOWN (резолв, шаг 3); display-имя CityName снимается из resolve при записи
UrgencybodyLeadUrgency? (enum-строка)Нетизвестное значение enum, unknown → ValidationError
PurchaseGoalbodyPurchaseGoal? (enum-строка)Нетизвестное значение enum
PreferredPaymentMethodbodyPaymentMethod? (enum-строка)Нетизвестное значение enum (purchase-program.md)
Commentbodystring?Нет0..2000PII — в логи не пишется

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

Read-проекция обновлённого агрегата LeadEntity (дом полей — lead.md); здесь — только дельта, релевантная операции.

ПолеТипОбязательностьИсточникMaskingОписание
LeadIdGuidДаlead.IdИдентификатор лида.
StatusLeadStatus (enum-строка)Даlead.StatusВсегда Active (операция разрешена только в этом статусе).
WishesLeadWishesModelДаlead.Wishes (owned VO)Обновлённая шапка хотелок (поля VO — lead.md).
UpdatedAtDateTimeOffsetДаlead.UpdatedAtМомент правки.
VersionlongДаlead.VersionДля оптимистичной блокировки на клиенте.

Остальные секции карточки (клиент, лента, сроки жизни) возвращаются без изменений — см. проекцию в lead.md.

Алгоритм

  1. Контекст и доступ. Операцию выполняет агент-владелец / Менеджер ПИН (permission crm.leads.manage) в рамках TenantId.
  2. Проверка входа и предусловий.
    • формат: LeadId != Guid.Empty; Budget.Min/Max >= 0 и Min <= Max при обоих заданных; Urgency/PurchaseGoal/PreferredPaymentMethod — известные enum-значения; Comment <= 2000 — иначе ошибка валидации;
    • лид существует — иначе CRM_LEAD_NOT_FOUND;
    • состояние: lead.Status == Active (INV-LEAD-2) — иначе CRM_LEAD_NOT_ACTIVE;
    • record-level (INV-LEAD-5): lead.TenantId == TenantId и (lead.ExecutorAgentUserId == actor или Менеджер ПИН) — иначе CRM_LEAD_FORBIDDEN.
  3. Резолв справочников (write-path, строгий). Если заданы CityId и/или BudgetCurrencyId — один batch-резолв в справочнике handbook (resolve-references.md):
    • город не найден или архивен → CRM_CITY_UNKNOWN (422); из resolve снимается display-имя города — оно сохранится в CityName (денормализация при записи);
    • валюта не найдена или архивна → VALIDATION_FAILED; BudgetCurrencyId не задан — используется валюта RUB справочника (RUB-only, ADR-0048 — типизированная ссылка, не мультивалютность);
    • справочник недоступен → CRM_HANDBOOK_UNAVAILABLE (503): валидация строгая, «тихая» запись непроверенных ссылок запрещена; оба id не заданы — резолв не вызывается.
  4. Что читаем. Лид (строка блокируется на время правки от гонки с одновременным редактированием шапки / продлением). Прочих агрегатов грузить не нужно: LeadWishes — owned-тип в графе лида.
  5. Бизнес-правила и переходы. Собрать новый VO LeadWishes { Budget, BudgetCurrencyId, CityId, CityName, Urgency, PurchaseGoal, PreferredPaymentMethod, Comment } (маппинг поле-в-поле из body; CityName — из resolve шага 3, при CityId = null — тоже null); полностью замещает owned-VO лида, ставит UpdatedByUserId. Повторно проверяются INV-LEAD-2 (Active) и INV-LEAD-5 (владелец/Менеджер ПИН). Переход статуса: Active → Active (без смены). Фиксации лида не изменяются (их FixationWishesOverride — независимый snapshot). Доменные события не поднимаются.
  6. Что меняется. Обновляются owned-поля хотелок лида + UpdatedAt/Version — атомарно; RowVersion даёт оптимистичную блокировку → при конфликте CRM_LEAD_CONCURRENCY.
  7. События. Ни доменных, ни интеграционных событий нет.
  8. Результат. Обновлённая карточка лида (шапка хотелок + мета) → HTTP 200.

Диаграмма

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

ТипДетали
Интеграционные событияnone (спецификация: событий нет)
Внешние вызовыbatch-резолв справочника handbook (resolve-references.md) — только при заданных CityId/BudgetCurrencyId; недоступность → отказ операции (строгая write-path-валидация)
Проекции / поискnone (хотелки в поиске клиентов/лидов не участвуют)
Уведомленияnone

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400Budget.Min > Budget.Max, BudgetCurrencyId не найден/архивен в справочнике, неизвестный enum, Comment > 2000«Проверьте корректность полей хотелок»no retry
CRM_LEAD_NOT_FOUND404лид не найден по LeadId в tenant«Лид не найден»no retry
CRM_CITY_UNKNOWN422CityId не найден или архивен в справочнике городов handbook (резолв, шаг 3)«Город не найден в справочнике»no retry (выбрать город из lookup)
CRM_LEAD_NOT_ACTIVE409Status != Active (INV-LEAD-2)«Редактировать хотелки можно только у активного лида»no retry
CRM_LEAD_FORBIDDEN403actor не владелец и не Менеджер ПИН (INV-LEAD-5)«Недостаточно прав на редактирование лида»no retry
CRM_LEAD_CONCURRENCY409конфликт RowVersion (параллельная правка/продление)«Лид изменён, обновите и повторите»retry (перечитать)
CRM_HANDBOOK_UNAVAILABLE503справочник handbook недоступен при заданных CityId/BudgetCurrencyId«Справочник временно недоступен, повторите позже»retry (backoff)

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

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

  • актор авторизован, имеет permission crm.leads.manage; для чужого лида — является Менеджером ПИН;
  • лид существует в TenantId актора и находится в статусе Active (INV-LEAD-2);
  • у лида может как быть, так и не быть активных заявок на фиксацию — это на операцию не влияет.

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

  • owned-VO LeadWishes полностью заменён значениями запроса; это семантика полной замены, а не патча: optional-поле, не переданное в теле, становится null (сбрасывается, включая пару CityId/CityName), кроме BudgetCurrencyId (default — валюта RUB справочника);
  • при заданном CityId записан денормализованный CityName — display-имя города из resolve на момент правки (последующие переименования города в справочнике его не «догоняют» до следующей перезаписи хотелок);
  • UpdatedByUserId = актор, UpdatedAt обновлён, Version инкрементирован;
  • статус лида остаётся Active (перехода нет), лента событий лида не пополняется;
  • заявки на фиксацию этого лида и их независимый snapshot FixationWishesOverride не изменены;
  • доменных и интеграционных событий не опубликовано; смежные контексты (rewards, dev-stats) не затронуты.

Acceptance Criteria

  • Happy path. Дано: активный лид, которым владеет актор-агент. Когда: агент отправляет корректную шапку хотелок. Тогда: 200, LeadWishes заменён целиком, Version +1, статус остаётся Active, заявки на фиксацию и их override не тронуты, событий нет.
  • Полная замена (сброс поля). Дано: у лида задан Urgency. Когда: PUT-запрос без поля Urgency. Тогда: 200, Urgency в VO становится null (замена всей шапки, а не частичный патч).
  • Город из справочника. Дано: живой город справочника handbook. Когда: PUT с его CityId. Тогда: 200, в хотелках сохранены CityId и снятый из resolve CityName.
  • Неизвестный/архивный город. Дано: CityId, отсутствующий в справочнике или архивный. Когда: отправка. Тогда: 422 CRM_CITY_UNKNOWN, изменений нет.
  • Справочник недоступен. Дано: задан CityId/BudgetCurrencyId, handbook не отвечает. Когда: отправка. Тогда: 503 CRM_HANDBOOK_UNAVAILABLE, изменений нет; без справочных id операция resolve не вызывает и проходит штатно.
  • Лид не активен. Дано: лид в статусе, отличном от Active (например, конвертирован/архивирован). Когда: правка хотелок. Тогда: 409 CRM_LEAD_NOT_ACTIVE, изменений нет.
  • Чужой лид. Дано: лид принадлежит другому агенту, актор не Менеджер ПИН. Когда: правка. Тогда: 403 CRM_LEAD_FORBIDDEN.
  • Некорректный бюджет/валюта. Дано: Budget.Min > Budget.Max или BudgetCurrencyId не найден/архивен в справочнике. Когда: отправка. Тогда: 400 VALIDATION_FAILED, изменений нет.
  • Конкурентная правка. Дано: лид параллельно изменён (правка шапки/продление срока жизни). Когда: отправка с устаревшей версией. Тогда: 409 CRM_LEAD_CONCURRENCY; клиент перечитывает карточку и повторяет.

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

  • Все поля тела — optional-хотелки (BudgetCurrencyId не задан → валюта RUB справочника); добавление новых optional-полей VO — additive; удаление/переименование/смена типа — breaking (migration + rollout). Unknown enum → отказ валидации (без «тихого» приведения). Справочные ссылки (CityId long, BudgetCurrencyId short) используют типы id источника handbook.
  • Logs: lead-wishes updated (LeadId, tenant, actor; Comment — исключается из логов); metrics: crm_lead_wishes_update_total{result}, latency p95; traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/.

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

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

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