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

GET /api/agent/crm/clients — Клиентская база агента (список + поиск)

Табличный список клиентской базы агентства (страница /crm/clients): карточки клиентов с пагинацией, сортировкой по последней активности и полнотекстовым поиском через поисковую проекцию ClientSearchDocument (../../search/clients.md, ADR-0059) — тот же класс проекций, что у дубль-детекта фиксаций (../../search/fixations.md). О клиентской базе не знает застройщик — это внутренние данные агентства (../../domain/client.md).

Назначение

Показать агенту клиентов, которых он ведёт: создатель записи (OriginAgentUserId = actor) или активный участник их процессов. Руководителю агентства и Менеджеру ПИН — отдать базу всего агентства с опциональным фильтром по агенту-создателю (OriginAgentUserId), чтобы находить клиентов конкретного агента. Поиск — fuzzy-match по ФИО, нормализованному телефону и email.

Metadata

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

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

Доступ
ПроверкаЗначение
Actor typeUser — Агент или Руководитель агентства; Operator — Менеджер ПИН (platform-scope, ADR-0052)
Auth policy / Permissionscrm.clients.view (каталог — ../../../identity/domain/permission.md)
Scope (RBAC)организация (агентство): TenantId = AgencyCompanyId
Record-levelагент — клиенты, где он создатель (OriginAgentUserId = actor) или активный участник процессов клиента; руководитель — весь TenantId c опциональным фильтром по агенту-создателю (OriginAgentUserId); Менеджер ПИН — кросс-tenant (platform-scope)
Tenant isolationTenantId = AgencyCompanyId в каждом запросе (авто-фильтр по scope)
Auditread (список PII); access-log без значений PhoneNumber/Email/поиска

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/agent/crm/clients
Success status200
Idempotency headerне применяется (GET)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claimsGuidДа (вычисляется)AgencyCompanyId; Required scopeTenant isolation
ActorUserIdidentity claimsGuidДа (вычисляется)из identityопределяет record-level и masking
Searchquery (search)string?Нетtrim, 1..128; пустой после trim → игнор; поисковый запрос multi-match (fuzzy), не raw-DSLPII: значение поиска в логи не пишется
Statusquery (status)ClientStatus? (string)НетActive / Archived (../../domain/client.md); null — все статусы
OriginAgentUserIdquery (originAgentUserId)Guid?Нет!= Guid.Empty; фильтр по агенту-создателю «свои/все» — только руководитель/Менеджер ПИН; null — вся база агентстваrecord-level
PagequeryintНет (default 1)>= 1
PageSizequeryintНет (default 20)1..100 (лента ~20)

Сортировка фиксирована — LastActivityAt desc (последняя активность сверху); поле сортировки клиентом не задаётся. Тай-брейк — CreatedAt desc, Id asc (стабильная пагинация).

Модель ответа — постраничный список ClientListItemModel

Постраничный контейнер: Items, Page, PageSize, TotalCount.

ПолеТипОбязательностьИсточникОписание
ItemsIReadOnlyList<ClientListItemModel>Да (может быть пустым)страница выборкиЭлементы страницы.
PageintДавходТекущая страница.
PageSizeintДавходРазмер страницы.
TotalCountlongДачисло клиентов под фильтромВсего клиентов под фильтром.

Вложенная модель ClientListItemModel (проекция ../../domain/client.md)

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаClient.IdПереход в карточку /crm/clients/[clientId].
NamestringДаClient.Name (FullName, собранное ФИО)ФИО клиента.
PhoneNumberstringДаClient.PhoneNumber (E.164)+7***NNNN в чужих спискахТелефон клиента (PII).
StatusClientStatus (string)ДаClient.StatusActive / Archived.
ActiveLeadIdGuid?НетClient.ActiveLeadIdТекущий активный лид (../../domain/lead.md) для быстрого перехода; null — активного лида нет.
LastActivityAtDateTimeOffset?НетClient.LastActivityAtМомент последней активности; ключ сортировки.
LeadsCountintДачисло лидов клиента (leads.client_id)Число лидов клиента (все статусы).

Алгоритм

  1. Контекст и доступ. Операцию выполняет агент/руководитель агентства/Менеджер ПИН (permission crm.clients.view) в tenant своего агентства (TenantId = AgencyCompanyId, при отсутствии scope — fail-closed). Право видеть всю базу агентства — permission crm.clients.view-all.
  2. Проверка входа и предусловий.
    • формат: Search — trim и 1..128; Status — допустимое значение enum; OriginAgentUserId != Guid.Empty при заданном; Page >= 1; PageSize в 1..100 — иначе ошибка валидации (400);
    • record-level: если у актора нет crm.clients.view-all и задан OriginAgentUserId != actorCRM_CLIENTS_FORBIDDEN_AGENT_FILTER (403): рядовой агент не листает базу другого агента.
  3. Что читаем. Эффективный доступ по членству агентства (../../domain/client.md): без view-all рядовой агент видит клиентов, где он создатель (OriginAgentUserId = actor) или активный участник их процессов (лид/фиксация/сделка); с правом — вся база агентства с опциональным фильтром по создателю (заданный OriginAgentUserId, или null = вся база). Основная выборка — поисковая проекция ClientSearchDocument со scope-фильтром TenantId (AgencyCompanyId) + фильтром доступа / по создателю (OriginAgentUserId), опциональным Status, сортировкой LastActivityAt desc (тай-брейк CreatedAt desc, Id asc), пагинацией Page/PageSize и TotalCount; архивные (soft-delete) документы в индекс не попадают; при заданном Search — multi-match (fuzzy) по ФИО+телефону+email. Актуальные PII отдаёт карточка (source-of-truth PostgreSQL). Счётчик LeadsCount — батч-агрегатом по клиентам страницы (без N+1).
  4. Маппинг и masking. Поле-в-поле ClientListItemModel; LeadsCount — из батч-агрегата (0, если лидов нет). PhoneNumber маскируется в +7***NNNN, если карточка чужого агента (руководитель/Менеджер ПИН смотрит чужой список — телефон чужого клиента скрыт; см. ../../domain/client.md, PII).
  5. Результат. Постраничный список → 200; пустая выдача → Items = [], TotalCount = 0 (валидное «ничего не найдено»). Ошибки — 400/403 ProblemDetails.

Диаграмма

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

ТипДетали
Интеграционные событияnone (клиентская база — внутренние данные агентства)
Внешние вызовыnone
Проекции / поискчтение поисковой проекции ClientSearchDocument (scope-фильтр, ADR-0059); проекций не пишет
Уведомленияnone

Ошибки и failure modes

Ошибки
КодHTTP statusУсловиеСообщениеRetry
VALIDATION_ERROR400Нарушение правил таблицы входных (Search длина, неизвестный Status, Page/PageSize)«Некорректные параметры запроса»no retry
CRM_CLIENTS_FORBIDDEN_AGENT_FILTER403Рядовой агент задал OriginAgentUserId ≠ свой (нет crm.clients.view-all)«Просмотр базы другого агента недоступен»no retry
FORBIDDEN403Нет permission crm.clients.view«Недостаточно прав»no retry
CRM_CLIENTS_SEARCH_UNAVAILABLE503Search задан, но поисковая проекция (Elasticsearch) недоступна«Поиск временно недоступен, повторите позже»retry
INTERNAL_ERROR500Инфраструктурный сбойgenericretry (backoff)
Деградация поисковой проекции

При недоступной проекции (../../README.md → зависимости) запрос без Search обслуживается из source-of-truth PostgreSQL (сортировка LastActivityAt desc, без fuzzy-ранжирования) — список остаётся доступным, это не ошибка (200). Запрос с Search в этом режиме выполнить нельзя (fuzzy-матчинг живёт только в проекции) → CRM_CLIENTS_SEARCH_UNAVAILABLE (503, retry).

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

  • Новые фильтры/поля ответа — additive; изменение default-сортировки или ужесточение валидации — breaking (фиксируется в changelog контекста). Enum-строки (ClientStatus) — клиентский fallback на unknown обязателен.
  • Logs: TenantId, actor type, effectiveAgentId, Status, hasSearch (без значения поиска и телефонов), TotalCount; metrics: crm_clients_list_requests_total{scope=own|all}, latency histogram (SLO списки p95 < 1 с, ../../README.md); traces — сквозной trace через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/.

Acceptance Criteria

  • Happy path. Given агент без фильтров; When GET; Then 200 — клиенты, которых он ведёт (создатель OriginAgentUserId = actor или участник процессов), сортировка LastActivityAt desc; телефоны своих клиентов — полностью.
  • База другого агента. Given руководитель с crm.clients.view-all и OriginAgentUserId другого агента; When GET; Then 200 — база того агента (по создателю); телефоны чужих клиентов маскируются +7***NNNN.
  • Фильтр без прав. Given рядовой агент без view-all задал OriginAgentUserId != actor; When GET; Then 403 CRM_CLIENTS_FORBIDDEN_AGENT_FILTER.
  • Поиск. Given заданный Search; When GET; Then fuzzy-выдача по ФИО + нормализованному телефону + email.
  • Пусто. Given фильтр без совпадений; When GET; Then 200, Items = [], TotalCount = 0.
  • Валидация. Given Search вне 1..128, неизвестный Status или Page/PageSize вне границ; When GET; Then 400 VALIDATION_ERROR.
  • Деградация (без поиска). Given проекция Elasticsearch недоступна и запрос без Search; When GET; Then 200 из PostgreSQL source-of-truth (сортировка LastActivityAt, без fuzzy).
  • Деградация (поиск). Given проекция недоступна и задан Search; When GET; Then 503 CRM_CLIENTS_SEARCH_UNAVAILABLE (retry).

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

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

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