Для AI-агентов: markdown этой страницы — /docs-content/recipes/crm-analytics.md индекс документации — /llms.txt

CRM-аналитика: воронка продаж

Сложность: начальный | Скоупы: crm | Стек: cURL / JavaScript

Считаем распределение сделок по стадиям воронки, конверсию в успешные и средний чек. Разбивку отдаёт один запрос агрегации — выгружать сами сделки не нужно.

Что понадобится

  • API-ключ Вайбкод со скоупом crm
  • Node.js 18 или новее для примеров на JavaScript, jq — для примера на cURL

Во всех примерах $VIBE_URL — базовый адрес https://vibecode.bitrix24.tech, $VIBE_API_KEY — ваш API-ключ.

Как устроено решение

  1. Получаем названия и порядок стадий воронки — они задаются на портале и в коде их держать нельзя.
  2. Одним запросом агрегации получаем по каждой стадии количество сделок и сумму.
  3. Считаем конверсию и средний чек из полученных групп.

Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».

Шаг 1. Названия и порядок стадий

Идентификаторы стадий (NEW, EXECUTING, WON) одинаковы не на всех порталах, а их названия администратор меняет свободно. Список стадий основной воронки возвращает GET /v1/statuses с фильтром по DEAL_STAGE.

cURL

Terminal
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/statuses?filter[entityId]=DEAL_STAGE"

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/statuses?filter[entityId]=DEAL_STAGE`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data: stages } = await res.json()
JSON
{
  "success": true,
  "data": [
    { "statusId": "NEW", "name": "Новая", "sort": 10, "semantics": null },
    { "statusId": "WON", "name": "Сделка успешна", "sort": 60, "semantics": "S" }
  ],
  "meta": { "total": 2, "hasMore": false }
}

Поле sort задаёт порядок стадий в воронке — по нему выстраивается отчёт. Для дополнительных воронок идентификатор фильтра другой: DEAL_STAGE_{categoryId}, где categoryId — номер воронки.

Шаг 2. Разбивка по стадиям

POST /v1/deals/aggregate с группировкой по stageId возвращает по каждой стадии количество сделок и запрошенные агрегаты. Фильтр categoryId: 0 ограничивает выборку основной воронкой.

cURL

Terminal
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/deals/aggregate" \
  -d '{
    "aggregate": [
      { "field": "amount", "function": "sum" },
      { "field": "amount", "function": "avg" }
    ],
    "filter": { "categoryId": 0 },
    "groupBy": "stageId"
  }'

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/deals/aggregate`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    aggregate: [
      { field: 'amount', function: 'sum' },
      { field: 'amount', function: 'avg' },
    ],
    filter: { categoryId: 0 },
    groupBy: 'stageId',
  }),
})
const { data } = await res.json()

В ответе data.count — всего сделок под фильтр, data.groups — разбивка по стадиям.

JSON
{
  "success": true,
  "data": {
    "count": 1217,
    "aggregates": { "amount": { "sum": 2337719.23, "avg": 1920.88 } },
    "groups": [
      { "stageId": "NEW", "count": 338, "aggregates": { "amount": { "sum": 144225.29, "avg": 426.7 } } },
      { "stageId": "EXECUTING", "count": 16, "aggregates": { "amount": { "sum": 1153146.44, "avg": 72071.65 } } },
      { "stageId": "WON", "count": 19, "aggregates": { "amount": { "sum": 2216, "avg": 116.63 } } }
    ],
    "meta": { "totalRecords": 1217, "recordsProcessed": 1217, "truncated": false, "groupTotal": 7, "groupsTruncated": false }
  }
}

Поле meta.truncated показывает, уложилась ли выборка целиком, а meta.groupsTruncated — попали ли в ответ все стадии.

Чтобы ограничить отчёт периодом, добавьте условие по дате создания в тот же filter.

JSON
{ "filter": { "categoryId": 0, "createdAt": { "$gte": "2026-01-01" } } }

Шаг 3. Конверсия и средний чек

Обе метрики считаются из групп, дополнительные запросы не нужны. Конверсия в успешные — доля сделок в стадии WON от всех сделок воронки. Средний чек по стадии приходит готовым в aggregates.amount.avg.

cURL

Terminal
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/deals/aggregate" \
  -d '{"aggregate":[{"field":"amount","function":"avg"}],"filter":{"categoryId":0},"groupBy":"stageId"}' \
| jq -r '
  .data as $d
  | ($d.groups[] | select(.stageId == "WON") | .count) as $won
  | "Всего сделок: \($d.count), выиграно: \($won), конверсия: \((100 * $won / $d.count) | floor) %",
    ($d.groups[] | "\(.stageId): \(.count), средний чек \(.aggregates.amount.avg // 0 | floor)")'

JavaScript

javascript
const byStage = new Map(data.groups.map(g => [g.stageId, g]))

// В дополнительных воронках стадия несёт префикс: C1:WON вместо WON.
const WON_STAGE = CATEGORY_ID === 0 ? 'WON' : `C${CATEGORY_ID}:WON`

const won = byStage.get(WON_STAGE)?.count ?? 0
const conversion = data.count ? (won / data.count) * 100 : 0

for (const stage of stages.sort((a, b) => a.sort - b.sort)) {
  const group = byStage.get(stage.statusId)
  if (!group) continue
  const avg = group.aggregates.amount.avg ?? 0
  console.log(`${stage.name}: ${group.count} сделок, средний чек ${Math.round(avg)} ₽`)
}

console.log(`Конверсия в успешные: ${conversion.toFixed(1)} %`)

Ограничения

Стадии привязаны к воронке. Стадии в разных воронках называются по-разному. В основной воронке идентификатор выглядит как NEW, в дополнительных — как C1:NEW с номером воронки в префиксе. Поэтому сравнивать stageId с голым NEW можно только внутри одной воронки, а отчёт по всем воронкам сразу собирается отдельными запросами с разными categoryId.

Пустые стадии. Стадии, в которых нет ни одной сделки, в groups не попадают. Отчёт строится по списку стадий из шага 1, а отсутствующая группа означает ноль.

Валюта не приводится. Сумма приходит в валюте самой сделки, а поле amount их не приводит к одной. Если на портале есть сделки в разных валютах, суммирование по стадии смешает их — для корректного итога отфильтруйте выборку по нужной валюте.

Очередь портала. При переполненной очереди портала запрос отклоняется до отправки в Битрикс24 — повторить его безопасно.

JSON
{
  "success": false,
  "error": {
    "code": "QUEUE_OVERFLOW",
    "message": "Portal queue overloaded — 128 Bitrix24 calls already pending",
    "userMessage": "Слишком много одновременных запросов к Bitrix24 — повторите через несколько секунд.",
    "hint": "Honor the Retry-After header. Use exponential backoff with jitter for repeated failures.",
    "retryAfter": 3
  }
}

Рекомендованная пауза дублируется заголовком Retry-After. Полный перечень кодов — Ошибки.

Полный код

javascript
// funnel-report.js — отчёт по воронке продаж
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Переменная окружения VIBE_API_KEY не задана')
const CATEGORY_ID = 0
// В основной воронке стадия успеха — 'WON', в дополнительных к ней
// добавляется префикс воронки: C1:WON. Без этого отчёт по любой
// воронке кроме основной молча показал бы нулевую конверсию.
const WON_STAGE = CATEGORY_ID === 0 ? 'WON' : `C${CATEGORY_ID}:WON`

const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
// Портал и платформа отвечают ошибкой в теле, а не только статусом. Без этой
// проверки деструктуризация даёт undefined и скрипт падает на первом обращении
// к полю — например TypeError вместо понятного 402 при нулевом балансе.
// Любой отказ 429 (QUEUE_OVERFLOW, QUEUE_TIMEOUT, RATE_LIMITED) означает,
// что запрос до Битрикс24 не дошёл, — повторить его безопасно. Сколько ждать, говорит заголовок
// Retry-After, а при его отсутствии поле error.retryAfter. К паузе добавляется
// случайная доля секунды, чтобы
// параллельные копии скрипта не пошли на повтор одновременно.
const MAX_RETRIES = 5

async function readData(url, init = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, init)
    const body = await res.json().catch(() => null)

    if (res.status === 429 && attempt < MAX_RETRIES) {
      // Рекомендованную паузу берём как есть: умножать её на номер попытки
      // нельзя — из Retry-After: 3 на пятой попытке вышло бы 48 секунд.
      // Экспонента нужна только когда сервер паузу не назвал.
      const advised = res.headers.get('Retry-After') ?? body?.error?.retryAfter ?? null
      // Потолок в 60 с: рекомендованную паузу мы уважаем, но неожиданно
      // большое значение от промежуточного прокси не должно вешать цикл.
      const base = advised === null ? Math.min(2 ** attempt, 30) : Number(advised)
      const wait = Math.min(base, 60) + Math.random()
      console.warn(`Очередь портала занята, повтор через ${Math.round(wait)} с`)
      await new Promise(resolve => setTimeout(resolve, wait * 1000))
      continue
    }

    if (!body?.success) throw new Error(body?.error?.message ?? `запрос отклонён (${res.status})`)
    return body.data
  }
}

async function fetchStages() {
  const entityId = CATEGORY_ID === 0 ? 'DEAL_STAGE' : `DEAL_STAGE_${CATEGORY_ID}`
  const data = await readData(`${VIBE_URL}/v1/statuses?filter[entityId]=${entityId}`, { headers })
  return data.sort((a, b) => a.sort - b.sort)
}

async function fetchFunnel() {
  return readData(`${VIBE_URL}/v1/deals/aggregate`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      aggregate: [
        { field: 'amount', function: 'sum' },
        { field: 'amount', function: 'avg' },
      ],
      filter: { categoryId: CATEGORY_ID },
      groupBy: 'stageId',
    }),
  })
}

async function main() {
  const [stages, funnel] = await Promise.all([fetchStages(), fetchFunnel()])
  const byStage = new Map(funnel.groups.map(g => [g.stageId, g]))

  console.log(`Всего сделок в воронке: ${funnel.count}`)

  for (const stage of stages) {
    const group = byStage.get(stage.statusId)
    const count = group?.count ?? 0
    const share = funnel.count ? (count / funnel.count) * 100 : 0
    const avg = group?.aggregates.amount.avg ?? 0
    console.log(
      `${stage.name.padEnd(26)} ${String(count).padStart(6)}  ${share.toFixed(1).padStart(5)} %  ` +
      `средний чек ${Math.round(avg)} ₽`,
    )
  }

  const won = byStage.get(WON_STAGE)?.count ?? 0
  const revenue = byStage.get(WON_STAGE)?.aggregates.amount.sum ?? 0
  console.log(`Конверсия в успешные: ${(funnel.count ? (won / funnel.count) * 100 : 0).toFixed(1)} %`)
  console.log(`Выручка по успешным: ${Math.round(revenue)} ₽`)
}

main().catch(console.error)

Смотрите также