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

POST /api/agent/crm/leads/{leadId}/extend — Продлить лид

Агент вручную продлевает срок жизни лида на +1 месяц (лид живёт ~месяц и продлевается). Операция закрывает сценарий из уведомления «продлите лид», которое notifications рассылает по событию lead-expired, и реализует инвариант INV-LEAD-3 (lead.md): продлевать можно и Active, и Expired — в последнем случае это реактивация связи «инвестор ↔ агент» с повторной проверкой INV-CRM-001. Дом полей и переходов — lead.md.

Назначение

Продлить срок жизни лида агента-владельца: ExpiresAt = max(now, ExpiresAt) + 1 месяц, ExtensionsCount += 1, при необходимости реактивировать истёкший лид (Expired → Active).

Инвариант

INV-LEAD-3: продлить можно и Active, и Expired лид — во втором случае это реактивация связи «агент ↔ инвестор» с повторной проверкой INV-CRM-001 (если у пары уже есть другой активный лид — 409 CRM_LEAD_EXTEND_CONFLICT). Операция не идемпотентна: каждый вызов сдвигает ExpiresAt на +1 месяц и увеличивает ExtensionsCount, независимо от количества предыдущих вызовов.

Metadata

ПолеЗначение
Сервис/контекстcrm
API areaagent
Feature grouplead
Статусdraft
Документdocs/06-services/crm/api/agent/extend-lead.md

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

ПроверкаЗначение
Actor typeUser (Агент-владелец; Руководитель агентства — в рамках своего TenantId)
Auth policy / Permissionscrm.leads.manage
Scope (RBAC)организация (агентство): агент — свои лиды; руководитель — весь TenantId
Record-levelLeadEntity.ExecutorAgentUserId == actor (агент-исполнитель) либо TenantId == actor.AgencyCompanyId (руководитель)
Tenant isolationTenantId = AgencyCompanyId в каждом запросе (ADR-0052)
Auditupdate (UpdatedByUserId = actor, Version)
Доступ

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

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/agent/crm/leads/{leadId}/extend
Success status200 (лид продлён; тело — обновлённая карточка)
Idempotency headerне применяется — продление не идемпотентно (каждый вызов = +1 месяц, ExtensionsCount += 1)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДаRequired; = AgencyCompanyIdTenant isolation
LeadIdrouteGuidДа!= Guid.Empty; существующий лид в TenantId
ActorUserIdidentity claimsGuidДаJWT агента; используется для record-level и LeadExtendedPayload.ExtendedByUserId

Тело запроса пустое (действие детерминировано: +1 месяц). Причина/срок не параметризуются — правило фиксированное (INV-LEAD-3).

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

Проекция агрегата — дом полей lead.md; ниже перечислена только дельта, значимая для продления. LeadCardModel — та же карточка лида, что возвращают list/get агента (полный состав — в доме).

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаLeadEntity.IdИдентификатор лида.
Statusstring (enum LeadStatus)ДаLeadEntity.StatusПосле операции — всегда Active (реактивация Expired).
ExpiresAtDateTimeOffsetДаLeadEntity.ExpiresAtНовый срок = max(now, prev) + 1 месяц.
ExtensionsCountintДаLeadEntity.ExtensionsCountИнкрементирован на 1.
LastExtendedAtDateTimeOffsetДаLeadEntity.LastExtendedAt= now (момент продления).
VersionlongДаLeadEntity.VersionОптимистическая версия после апдейта.

Алгоритм

  1. Контекст и доступ. Операцию выполняет агент-владелец / руководитель агентства (permission crm.leads.manage) в рамках TenantId.
  2. Проверка входа и предусловий.
    • формат: LeadId != Guid.Empty — иначе ошибка валидации;
    • лид существует в TenantId — иначе CRM_LEAD_NOT_FOUND;
    • допустимость состояния: Status ∈ {Active, Expired}; ArchivedCRM_LEAD_NOT_EXTENDABLE (архивный лид не продлевается — только пересоздаётся);
    • record-level: лид того же tenant и агент — владелец (или руководитель того же TenantId) — иначе CRM_LEAD_FORBIDDEN.
  3. Что читаем. Лид (строка блокируется на время операции от гонки с worker-ом lead-expiration и параллельными продлениями). Если Status == Expired (реактивация) — проверить INV-CRM-001: нет ли другого Active-лида на ту же пару (агент, инвестор), кроме текущего.
  4. Бизнес-правила и переходы (INV-LEAD-3). ExpiresAt = max(now, ExpiresAt) + 1 месяц; ExtensionsCount += 1; LastExtendedAt = now. Если Status == Expired и найден другой Active-лид пары (INV-CRM-001) → CRM_LEAD_EXTEND_CONFLICT; иначе переход Expired → Active, ExpiredAt = null (реактивация). В ленту лида добавить событие LeadEventType.LeadExtended (payload LeadExtendedPayload { NewExpiresAt = ExpiresAt, ExtendedByUserId = actor }, INV-LEAD-2) и обновить LastEventAt.
  5. Что меняется. Лид (сроки, счётчик, статус) + новое событие ленты — в одном согласованном изменении.
  6. События. Интеграционных Kafka-событий нет: продление — внутренняя операция агента (топика pin.crm.lead-extended не существует, см. README). Реактивация Expired → Active не порождает lead-created (это не новый лид); истечение публикует только worker — lead-expired.
  7. Результат. Обновлённая карточка лида (Status = Active, новый ExpiresAt, ExtensionsCount, LastExtendedAt) → HTTP 200. При гонке INV-CRM-001 (реактивация) — CRM_LEAD_EXTEND_CONFLICT.

Диаграмма

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

Предусловия. Актор — владелец лида или руководитель агентства с crm.leads.manage, TenantId = AgencyCompanyId. Лид существует в tenant, Status ∈ {Active, Expired} (не Archived). При реактивации (Expired) у пары (агент, инвестор) нет другого Active-лида (INV-CRM-001).

Постусловие (успех). ExpiresAt = max(now, prev) + 1 месяц, ExtensionsCount += 1, LastExtendedAt = now. Если лид был Expired → стал Active, ExpiredAt = null (реактивация). В ленте лида добавлено событие LeadExtended, обновлён LastEventAt. Интеграционных событий нет (операция внутренняя для агентства).

Постусловие (отказ). Лид не изменён (сроки/счётчик/статус), событие ленты не добавлено.

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

ТипДетали
Интеграционные событияnone (продление kafka-событий не публикует)
Внешние вызовыnone
Проекции / поискLastEventAt обновлён → пересортировка списка лидов агента (индекс по последней активности)
Уведомленияnone (агент сам инициировал; уведомление «продлите» было по lead-expired)

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400LeadId = Guid.Empty«Некорректный идентификатор лида»no retry
CRM_LEAD_NOT_FOUND404лид не найден в TenantId«Лид не найден»no retry
CRM_LEAD_FORBIDDEN403не владелец / чужой tenant«Нет доступа к лиду»no retry
CRM_LEAD_NOT_EXTENDABLE409Status = Archived«Архивный лид нельзя продлить»no retry
CRM_LEAD_EXTEND_CONFLICT409реактивация Expired нарушает INV-CRM-001 (у пары уже есть Active-лид; либо гонка)«У клиента уже есть активный лид с этим агентом»no retry

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

  • Route/тело стабильны; добавление optional-полей в LeadCardModel — additive (без breaking); удаление/ переименование/смена типа — breaking (migration + rollout). Enum LeadStatus — строкой; unknown-значение на входе не встречается (поле выходное).
  • Logs: lead-extend requested/succeeded/conflict (LeadId, TenantId, ExtensionsCount; PII не пишется); metrics: crm_lead_extend_total{result}, crm_lead_extensions_count (гистограмма); traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/.

Acceptance Criteria

  • Happy path (Active). Given Active-лид с ExpiresAt в будущем; When extend; Then 200, ExpiresAt = прежний + 1 месяц, ExtensionsCount += 1, событие ленты LeadExtended.
  • Просроченный срок у Active. Given Active-лид с ExpiresAt в прошлом (worker ещё не подхватил); When extend; Then новый ExpiresAt = now + 1 месяц (а не prev + 1 месяц).
  • Реактивация. Given Expired-лид, у пары нет другого Active-лида; When extend; Then 200, Status = Active, ExpiredAt = null.
  • Конфликт реактивации (INV-CRM-001). Given Expired-лид и у пары уже есть другой Active-лид; When extend; Then 409 CRM_LEAD_EXTEND_CONFLICT.
  • Архивный лид. Given Status = Archived; When extend; Then 409 CRM_LEAD_NOT_EXTENDABLE.
  • Неидемпотентность. Given два extend подряд; Then срок сдвинут на +2 месяца, ExtensionsCount += 2.
  • Доступ. Given чужой лид не-руководителю; When extend; Then 403 CRM_LEAD_FORBIDDEN.

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

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

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