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

GET /api/agent/crm/fixations — Список фиксаций агентства (+ поиск)

Список заявок на фиксацию со стороны агентства: агент видит свои, руководитель агентства — все по своей компании, с фильтрами агент/застройщик/статус и полнотекстовым поиском по ФИО+телефон. Дом полей — fixation-request.md.

Назначение

Отдать агенту/руководителю агентства страницу фиксаций его агентства для экрана «Мои фиксации». Ключевая особенность tenancy: заявка TenantId = DeveloperCompanyId (владелец решения — застройщик, ADR-0052), а актор — агентская сторона. Поэтому выборка идёт не по автоматическому tenant-scope, а по явному record-level предикату OriginAgencyCompanyId = actor.CompanyId (см. Актор и доступ и fixation-request.md → Безопасность).

Metadata

ПолеЗначение
Сервис/контекстcrm
API areaagent (JWT агента/руководителя агентства; permission crm.fixations.view)
Feature groupfixations
Статусdraft
Документdocs/06-services/crm/api/agent/list-fixations.md

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

ПроверкаЗначение
Actor typeUser (Агент или Руководитель агентства)
Auth policy / Permissionscrm.fixations.view; инвестор доступа не имеет
Scope (RBAC)организация (агентство) — actor.CompanyId = OriginAgencyCompanyId
Record-levelАгент — только свои (ExecutorAgentUserId = actor.UserId, текущий исполнитель); Руководитель агентства — все по OriginAgencyCompanyId с опциональным фильтром по исполнителю
Tenant isolationОсобый случай: TenantId заявки = DeveloperCompanyId ≠ scope актора. Автоматический tenant-фильтр отключается; изоляция гарантируется обязательным предикатом OriginAgencyCompanyId = actor.CompanyId + DeletedAt is null. Кросс-агентский доступ конструктивно невозможен (значение берётся из claims, не из запроса)
Auditread (не sensitive: агентство запрашивает собственные данные); в логи — только Id/счётчики, телефон маскируется
Доступ

Особый случай tenancy: TenantId заявки = DeveloperCompanyId (владелец решения — застройщик), а не OriginAgencyCompanyId актора. Автоматический tenant-фильтр по этой причине отключается — изоляцию агентства гарантирует обязательный предикат OriginAgencyCompanyId = actor.CompanyId (значение берётся из claims, не из запроса) + DeletedAt is null. Кросс-агентский доступ конструктивно невозможен.

HTTP-контракт

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

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdidentity claimsGuidДаRequiredRecord-level (агент — свои по ExecutorAgentUserId)
ActorCompanyIdidentity claimsGuidДаRequired, != Guid.Emptyагентство актора; сверяется с OriginAgencyCompanyId заявки — обязательный предикат выборки
IsAgencyManageridentity claims (роль)boolДаиз роли/permission руководителяОпределяет record-level (свои vs всё агентство)
ExecutorAgentUserIdqueryGuid?Нет!= Guid.Empty если задан; для агента разрешён только = ActorUserId (иначе CRM_FIXATIONS_FORBIDDEN)Фильтр руководителя по текущему исполнителю
DeveloperCompanyIdqueryGuid?Нет!= Guid.Empty если заданФильтр по застройщику (= TenantId заявки)
StatusqueryFixationStatus?Нетenum строкой; неизвестное значение → ValidationError
SearchTermquerystring?Нетtrim, 1..128 после trim; поисковый запрос multi-match (fuzzy), не raw-DSLPII (телефон/ФИО): в логи не пишется
PagingqueryPagedQueryНетTake ∈ [1..200] (default 20), Offset >= 0
SortquerySortQuery<FixationSortField>?НетFixationSortField ∈ {CreatedAt, DecidedAt, Status}; default CreatedAt desc (при поиске — relevance desc)

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

Постраничный контейнер: Items: IReadOnlyList<FixationListItemModel>, Meta: {Total, Take, Offset, HasMore}.

FixationListItemModel

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаFixationRequestEntity.IdИдентификатор заявки (ключ перехода на карточку).
ClientNameFullNameViewДаClientName (VO FullName, snapshot)ФИО клиента; контракт VO — client.md.
ClientPhoneNumberstringДаClientPhoneNumber (snapshot, E.164)+7***NNNN только в логахТелефон клиента (собственные данные агентства — в ответе не маскируется).
DeveloperCompanyIdGuidДаTenantIdЗастройщик-адресат; display-имя резолвится фронтом/identity.
StatusFixationStatusДаStatusenum строкой: Pending / FixedUnique / FixedNonUnique / Rejected / Cancelled (дом).
UniquenessFixationUniquenessДаUniquenessenum строкой: Unknown / NoDuplicates / HasDuplicates (результат дубль-проверки).
DecidedAtDateTimeOffset?НетDecidedAtМомент решения застройщика; null — ещё Pending/Cancelled.
CreatedAtDateTimeOffsetДаCreatedAtПодача заявки (ключ сортировки по умолчанию).

Алгоритм

  1. Контекст и доступ. Операцию выполняет агент/руководитель агентства (permission crm.fixations.view); якорь — ActorCompanyId (AgencyCompanyId); признак IsAgencyManager определяет охват (свои vs всё агентство).
  2. Проверка входа и предусловий.
    • формат: Paging.Take ∈ [1..200], Offset >= 0; SearchTerm (если задан) — 1..128 после trim; Status — известное значение enum; ExecutorAgentUserId/DeveloperCompanyId!= Guid.Empty, если заданы — иначе ошибка валидации;
    • scope: ActorCompanyId != Guid.Empty (иначе CRM_FIXATIONS_FORBIDDEN); если !IsAgencyManager и ExecutorAgentUserId != ActorUserIdCRM_FIXATIONS_FORBIDDEN (агент не смотрит чужие фиксации);
    • несуществующие фильтры дают пустую страницу, не ошибку.
  3. Что читаем. Выборка со scope-фильтром по агентству (авто-tenant-фильтр отключён; иначе фильтр tenant_id = DeveloperCompanyId не соответствовал бы скоупу агентства). При заданном SearchTerm — предварительный отбор идентификаторов через поисковую проекцию FixationRequestSearchDocument (multi-match по ФИО+телефон, fuzzy; scope-фильтр = agency record-level, ADR-0059).
  4. Бизнес-правила и построение предиката. Обязательная база (никогда не опускается): OriginAgencyCompanyId == ActorCompanyId и DeletedAt == null. Record-level: если не руководитель — добавить ExecutorAgentUserId == ActorUserId; иначе — опциональный ExecutorAgentUserId == query.ExecutorAgentUserId. Опциональные фильтры: TenantId == DeveloperCompanyId, Status == query.Status. Поиск: при заданном SearchTerm — ограничение по найденным id, сортировка по relevance desc; иначе — Sort (default CreatedAt desc). Маппинг поле-в-поле — по таблице выше.
  5. Результат. Постраничный список (Items — проекция страницы, MetaTotal по тому же предикату + Take/Offset) → HTTP 200. PII: ClientPhoneNumber отдаётся владеющему агентству без маски; в логи — только Id/Total.

Диаграмма

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

ТипДетали
Интеграционные событияnone (read-only)
Внешние вызовыnone (display-имена застройщиков/агентов резолвятся отдельно на фронте)
Проекции / поискпри поиске — чтение поисковой проекции FixationRequestSearchDocument (scope-фильтр, ADR-0059); PostgreSQL-индексы фильтров: ix_fixation_requests_origin_agency_company_id, ix_fixation_requests_executor_agent_user_id, ix_fixation_requests_status, ix_fixation_requests_created_at
Уведомленияnone

Ошибки и failure modes

Ошибки

Рядовой агент не может запросить чужой ExecutorAgentUserId — попытка всегда 403 CRM_FIXATIONS_FORBIDDEN. Несуществующие/чужие значения фильтров (DeveloperCompanyId, Status) не считаются ошибкой — они дают пустую страницу.

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400Take/Offset вне диапазона, неизвестный Status, SearchTerm > 128, пустой Guid-фильтр«Проверьте параметры запроса»no retry
CRM_FIXATIONS_FORBIDDEN403нет AgencyCompanyId в claims; агент запросил ExecutorAgentUserId != self«Недостаточно прав для просмотра фиксаций»no retry
UNAUTHORIZED401нет/невалидный JWT или отсутствует crm.fixations.view«Требуется авторизация»no retry
CRM_FIXATIONS_SEARCH_UNAVAILABLE503SearchTerm задан, но поисковая проекция FixationRequestSearchDocument (Elasticsearch) недоступна«Поиск временно недоступен, повторите позже»retry
Деградация поиска

Базовый список (без SearchTerm) читается из PostgreSQL по индексам фильтров и не зависит от Elasticsearch; деградирует только путь с поиском (см. ../../README.md → зависимости).

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

  • Новые optional фильтры/поля ответа — additive (без breaking). Удаление/переименование полей или сужение enum FixationStatus/FixationUniqueness — breaking (миграция клиента + rollout); неизвестный член enum на клиенте — по правилу unknown-enum.
  • Logs: agent-fixations listed (ActorUserId, ActorCompanyId, Total, флаг fts; телефон/ФИО — не пишутся); metrics: crm_fixations_list_total{scope=own|agency,fts}, latency p95 (< 1 c, SLO списков); traces — сквозной trace через traceparent; dashboard pin-crm.

Acceptance Criteria

  • Happy path. Given агент без фильтров; When GET; Then 200 — свои фиксации (ExecutorAgentUserId = actor), сортировка CreatedAt desc; ClientPhoneNumber в ответе без маски (собственные данные агентства).
  • Фильтр по агенту. Given руководитель агентства с фильтром ExecutorAgentUserId; When GET; Then 200 — фиксации указанного исполнителя в границах OriginAgencyCompanyId.
  • Scope запрещён. Given рядовой агент задал ExecutorAgentUserId != ActorUserId; When GET; Then 403 CRM_FIXATIONS_FORBIDDEN.
  • Поиск. Given заданный SearchTerm; When GET; Then multi-match (fuzzy) по ФИО + телефону, сортировка по relevance desc.
  • Фильтры. Given DeveloperCompanyId/Status; When GET; Then выдача сужена; несуществующие значения дают пустую страницу (не 404).
  • Валидация. Given Take/Offset вне диапазона, неизвестный Status или SearchTerm длиннее 128; When GET; Then 400 VALIDATION_FAILED.
  • Деградация поиска. Given задан SearchTerm, но проекция FixationRequestSearchDocument недоступна; When GET; Then 503 CRM_FIXATIONS_SEARCH_UNAVAILABLE (retry); запрос без поиска отрабатывает штатно.

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

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

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