GET /api/v1/dev-stats/conversion-funnel — Воронка конверсии (просмотр→сделка)
Назначение
Воронка конверсии недвижимости застройщика: просмотр → избранное → подборка → лид → бронь → сделка
(со ступенями bookingsPaid/dealsCompleted) — как внимание к его объектам превращается в лиды и сделки.
Источник блока «Воронка» на дашборде застройщика (../../README.md, метрика «Воронка»).
Считается из дневного rollup-а funnel_daily (AggregatingMergeTree,
uniqMerge по этапам, без FINAL); конверсии между этапами — производные при чтении. За флагом
dev-stats.funnel.enabled (нижние этапы активируются по мере готовности источников selections/crm).
| Поле | Значение |
|---|
| Сервис/контекст | dev-stats |
| API area | developers |
| Feature group | conversion-funnel |
| Статус | draft |
| Документ | docs/06-services/dev-stats/api/developers/get-conversion-funnel.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Bearer JWT; роль застройщика — developer) |
| Auth policy / Permissions | permission dev-stats.view — identity/domain/permission.md; feature flag dev-stats.funnel.enabled |
| Scope (RBAC) | организация застройщика; DeveloperId строго = актор (fail-closed) |
| Record-level | не применяется (агрегаты) |
| Tenant isolation | TenantId = DeveloperId обязателен и = актору |
| Audit | none (read-агрегаты; персональные данные лидов/клиентов не выдаются) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/dev-stats/conversion-funnel |
| Success status | 200 |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
Общий вход — DevStatsFilterModel (../../domain/dev-stats-filter.md).
Воронка не разбивается по типу зрителя (это про конверсию объектов недвижимости, не про сегмент аудитории — см.
funnel_daily): ViewerKind/ViewSource игнорируются.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
DeveloperId | identity claims | Guid | Да | != Guid.Empty; = актор (иначе Forbidden) | Tenant isolation |
DateFrom | query dateFrom | DateOnly | Да | ISO; DateFrom <= DateTo | — |
DateTo | query dateTo | DateOnly | Да | ISO; ≤ сегодня (UTC) | — |
ProjectId | query projectId | Guid? | Нет | reference dim_project (существование не проверяется) | — |
BuildingId | query buildingId | Guid? | Нет | требует ProjectId | — |
LayoutGroupId | query layoutGroupId | Guid? | Нет | — | — |
LayoutId | query layoutId | Guid? | Нет | — | — |
PropertyObjectId | query propertyObjectId | Guid? | Нет | — | — |
Модель ответа ConversionFunnelResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
dateFrom | string (ISO date) | Да | echo | — | Начало периода. |
dateTo | string (ISO date) | Да | echo | — | Конец периода. |
stages | IReadOnlyList<ConversionStageModel> | Да | агрегация funnel_daily | — | Этапы воронки по порядку (views→…→dealsCompleted). |
viewToDealPercent | decimal? | Нет | round1(deals / views × 100); null при views=0 | — | Сквозная конверсия просмотр→сделка, %. |
ConversionStageModel
| Поле | Тип | Обязательность | Источник | Описание |
|---|
stage | string (enum wire) | Да | views/favorites/selections/leads/bookings/bookingsPaid/deals/dealsCompleted | Этап воронки. |
count | long | Да | uniqMerge(<StageState>) | Уникальный счётчик этапа. |
conversionFromPrevPercent | decimal? | Нет | round1(count / prev.count × 100); null для views или prev=0 | Конверсия от предыдущего этапа, %. |
conversionFromViewsPercent | decimal? | Нет | round1(count / views × 100); null при views=0 | Конверсия от вершины (просмотров), %. |
enabled | bool | Да | флаг источника этапа | false для этапов, чей источник ещё не активирован (selections — future) — count=0, конверсии null. |
Enum этапов FunnelStage: Views/Favorites/Selections/Leads/Bookings/BookingsPaid/Deals/
DealsCompleted (wire — нижний регистр).
Предусловия и согласованность данных
- Пользователь застройщика авторизован; есть permission
dev-stats.view; scope DeveloperId = актор
(INV-STA-1, fail-closed — ../../README.md). Флаг dev-stats.funnel.enabled включён,
иначе FeatureDisabled (404).
- Этапы
funnel_daily наполняются разными продюсерами и приходят как событийная цепочка (все асинхронно,
каждый со своим окном свежести):
views — из pin.user-activity.property-object-view-ended.v1 (верхний этап, считается независимо);
favorites — из pin.favorites.favorite-added.v1;
selections — из pin.selections.selection-item-added.v1;
leads/bookings/bookingsPaid/deals/dealsCompleted — из
pin.crm.{lead-created,booking-created,booking-paid,deal-created,deal-completed}.v1.
- Порядок появления этапов между собой не согласован: нижние этапы отстают сильнее верхних, поэтому в
промежуточном состоянии воронка может показывать «сужение больше реального» — это eventual consistency
(INV-STA-5), а не ошибка. Счётчики этапов идемпотентны к повтору по бизнес-ключу этапа.
- Этап, чей источник ещё не активирован, отдаётся с
enabled=false, count=0 и конверсиями null (не
исключается из ответа — стабильная форма).
- Операция read-only: состояние не меняется, события не публикуются.
Алгоритм
- Контекст и доступ. Операцию выполняет пользователь застройщика (роль developer) с правом
dev-stats.view; воронка доступна только при включённом флаге dev-stats.funnel.enabled (иначе
FeatureDisabled 404). DeveloperId берётся из identity-claims актора; если клиент прислал иной id →
отказ Forbidden (403).
- Проверка входа.
DateFrom <= DateTo. Существование иерархических id не проверяется: неизвестный id
даёт нулевую воронку.
- Что читаем. Дневной rollup
funnel_daily за период по scope. Scope-фильтр — префикс-скан по ключу
(DeveloperId, ProjectId, BuildingId, LayoutGroupId, LayoutId, PropertyObjectId, EventDate); диапазон
дат режет партиции toYYYYMM(EventDate). Один итог на весь scope (GROUP BY ()) — разбивки по
ViewerKind в воронке нет.
- Расчёты и маппинг. Меры каждого этапа —
uniqMerge(Views), uniqMerge(Favorites),
uniqMerge(Selections), uniqMerge(Leads), uniqMerge(Bookings), uniqMerge(BookingsPaid),
uniqMerge(Deals), uniqMerge(DealsCompleted) (dedup-tolerant, без FINAL — идемпотентно к
at-least-once по бизнес-ключам этапов). Конверсии — производные: conversionFromPrev = count / prevCount,
conversionFromViews = count / views, viewToDeal = deals / views (guard знаменателя > 0). enabled
этапа = готовность источника (например selections — false до активации события selection-item-added).
- Результат. Возвращается
ConversionFunnelResult (200): stages и viewToDealPercent. Freshness
≤ 5 мин (верхний этап views считается независимо от нижних); недоступность ClickHouse →
DEV_STATS_UNAVAILABLE (503).
Диаграмма
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | Невалидный диапазон/иерархия | Ошибка валидации запроса | no retry |
UNAUTHORIZED | 401 | Нет JWT | Требуется авторизация | no retry |
Forbidden | 403 | DeveloperId != актор или нет dev-stats.view | Доступ запрещён | no retry |
FeatureDisabled | 404 | dev-stats.funnel.enabled выключен | Воронка недоступна | no retry |
DEV_STATS_UNAVAILABLE | 503 | ClickHouse недоступен / timeout | Аналитика временно недоступна | retry с backoff |
Пустая воронка (нет активности) — не ошибка: 200, stages с нулями, конверсии null.
Acceptance Criteria
- Полная воронка. Дано авторизованный застройщик, включённый флаг и период с активностью. Когда
GET /conversion-funnel. Тогда 200, stages в порядке views→…→dealsCompleted, для каждого этапа
conversionFromPrevPercent/conversionFromViewsPercent, плюс сквозная viewToDealPercent.
- Флаг выключен. Дано
dev-stats.funnel.enabled=false. Когда запрос. Тогда 404 FeatureDisabled
(воронка недоступна).
- Этап без источника. Дано источник
selections ещё не активирован. Когда запрос. Тогда его этап —
enabled=false, count=0, конверсии null; остальные этапы считаются нормально.
- Guard конверсий. Дано
views=0 (или prev=0 у этапа). Когда считаются доли. Тогда соответствующие
conversionFrom*Percent/viewToDealPercent = null, а не деление на ноль.
- Верхний этап независим. Дано нижние этапы ещё отстают (лаг crm-консюмера). Когда запрос. Тогда
views
корректны, нижние счётчики монотонно догоняют по мере ingestion (INV-STA-5) — промежуточное «пере-сужение»
не ошибка.
- Без разбивки по зрителю. Дано заданы
viewerKind/viewSource. Когда запрос. Тогда они игнорируются
(воронка — про конверсию объектов, не про сегмент аудитории).
- Пустая воронка. Дано нет активности в scope. Когда запрос. Тогда
200, stages с нулями, конверсии
null (не ошибка).
- Чужой scope / нет права. Дано чужой
DeveloperId или нет dev-stats.view. Когда запрос. Тогда
403 Forbidden (INV-STA-1).
- Недоступность витрины. Дано ClickHouse недоступен. Когда запрос. Тогда
503 DEV_STATS_UNAVAILABLE
(retry с backoff).
Совместимость и наблюдаемость
- Новые optional поля/этапы — additive (при append в хвост); удаление/переименование/смена порядка этапов
(
stage wire) — breaking (версия метода). Пока источник этапа не активирован — enabled=false,
а не breaking-исключение этапа.
- Logs:
conversion-funnel-read (developerId, scope-level, elapsed; PII нет). Metrics:
dev_stats_conversion_funnel_requests_total, latency histogram (SLO p95 ≤ 300 мс). Traces:
span приёма → span обработки → span чтения ClickHouse; correlation — сквозной trace traceparent.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы