Как получить воронки сделок со стадиями и семантикой

Scope: crm

Кто может выполнять методы: любой пользователь с доступом к CRM

Выберите инструмент для разработки с AI-агентом:

  • используйте Битрикс24 Вайбкод, чтобы создать приложение для Битрикс24 по описанию задачи без знания языков программирования. Агент напишет код и разместит приложение на сервере без ручной настройки хостинга
  • используйте MCP-сервер, чтобы разрабатывать интеграцию через REST API в своем проекте. Агент будет обращаться к официальной REST-документации

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

Семантика нужна для отчетов, автоматизации и фильтрации сделок. Например, по ней можно отделить активные сделки от выигранных и проигранных, даже если названия стадий отличаются в разных воронках.

В результате вы получите таблицу для каждой воронки сделок. В строках таблицы будут стадии, их названия и семантика.

Чтобы получить воронки сделок со стадиями и семантикой, вызываем последовательно два метода:

  1. crm.category.list — получаем массив categories с воронками сделок и берем из него id и name
  2. crm.status.list — для каждой воронки передаем код стадий в filter.ENTITY_ID и получаем стадии

Данные переходят между методами так:

  • id воронки из categories определяет код стадий
  • для основной воронки с id = 0 используем код DEAL_STAGE
  • для дополнительной воронки с id > 0 используем код DEAL_STAGE_{id}
  • сформированный код передаем в filter.ENTITY_ID метода crm.status.list

1. Получаем список воронок сделок

Вызываем метод crm.category.list с параметром entityTypeId: 2, где 2 — идентификатор типа объекта сделка. Идентификаторы типов объектов CRM можно получить методом crm.enum.ownertype.

Список воронок фильтруется по правам доступа пользователя. Если пользователь не может читать определенную воронку, метод не вернет ее в ответе.

Как использовать примеры в документации

const categoryResponse = await $b24.actions.v2.call.make({
    method: 'crm.category.list',
    params: {
        entityTypeId: 2,
    },
    requestId: 'category-list',
})

if (!categoryResponse.isSuccess) {
    throw new Error(categoryResponse.getErrorMessages().join('; '))
}

const arCategory = categoryResponse.getData().result.categories.reduce((acc, item) => {
    acc[item.id] = item.name
    return acc
}, {})
categories = client.crm.category.list(entity_type_id=2).response.result.get("categories", [])
category_map = {item["id"]: item["name"] for item in categories}
// crm.category.list не имеет типизированной обертки — вызываем через core
$result = $sb->core->call('crm.category.list', ['entityTypeId' => 2])
    ->getResponseData()
    ->getResult();

$arCategory = array_column($result['categories'] ?? [], 'name', 'id');
// 1. Список воронок сделок.
res, err := client.Core().Call(ctx, "crm.category.list", b24.Params{
	"entityTypeId": 2,
}, b24.WithIdempotent())
if err != nil {
	return fmt.Errorf("crm.category.list: %w", err)
}

// Метод заворачивает ответ в объект с ключом categories.
var categories struct {
	Categories []struct {
		ID   int    `json:"id"`
		Name string `json:"name"`
	} `json:"categories"`
}
if err := json.Unmarshal(res.Result, &categories); err != nil {
	return fmt.Errorf("разбор воронок: %w", err)
}

В ответе метод возвращает массив categories с доступными пользователю воронками сделок, включая основную. У каждой воронки есть id — идентификатор воронки, name — название, isDefault — признак основной воронки.

{
    "result": {
        "categories": [
            {
                "id": 0,
                "name": "Общая",
                "sort": 100,
                "entityTypeId": 2,
                "isDefault": "Y"
            },
            {
                "id": 7,
                "name": "Продление договора",
                "sort": 200,
                "entityTypeId": 2,
                "isDefault": "N"
            }
        ]
    },
    "total": 2
}

Поле total показывает общее количество найденных воронок. Метод возвращает одну страницу ответа — до 50 записей. Примеры выше обрабатывают полученные в ответе элементы.

2. Получаем стадии и семантику для каждой воронки

Метод crm.status.list получает стадии по фильтру ENTITY_ID. Для сделок код стадий зависит от воронки:

  • DEAL_STAGE — стадии основной воронки
  • DEAL_STAGE_{id} — стадии дополнительной воронки, где {id} — идентификатор воронки

Берем поле id из ответа crm.category.list, формируем ENTITY_ID и вызываем crm.status.list с сортировкой по SORT. Например, для воронки с идентификатором 7 нужно передать DEAL_STAGE_7.

В ответе используем поля:

  • STATUS_ID — идентификатор стадии
  • NAME — название стадии
  • EXTRA.SEMANTICS — семантика стадии
  • EXTRA.COLOR — цвет стадии

Значение EXTRA.SEMANTICS показывает группу стадии:

  • process — сделка находится в работе
  • success — сделка успешно завершена
  • failure — сделка завершена неуспешно
  • apology — отдельная группа неуспешно завершенных стадий

В примерах ниже используются данные, полученные на предыдущем шаге.

for (const [id, name] of Object.entries(arCategory)) {
    const entityId = Number(id) > 0 ? `DEAL_STAGE_${id}` : 'DEAL_STAGE'

    const stageResponse = await $b24.actions.v2.call.make({
        method: 'crm.status.list',
        params: {
            order: { SORT: 'ASC' },
            filter: { ENTITY_ID: entityId },
        },
        requestId: `status-list-${id}`,
    })

    if (!stageResponse.isSuccess) {
        console.error(stageResponse.getErrorMessages().join('; '))
        continue
    }

    for (const item of stageResponse.getData().result) {
        console.log(name, item.STATUS_ID, item.NAME, item.EXTRA?.SEMANTICS)
    }
}
for category_id, category_name in category_map.items():
    entity_id = f"DEAL_STAGE_{category_id}" if int(category_id) > 0 else "DEAL_STAGE"
    result_deal = client.crm.status.list(
        order={"SORT": "ASC"},
        filter={"ENTITY_ID": entity_id},
    ).response.result

    for item in result_deal:
        print(
            category_name,
            item.get("STATUS_ID", ""),
            item.get("NAME", ""),
            (item.get("EXTRA") or {}).get("SEMANTICS", ""),
        )
foreach ($arCategory as $id => $name)
{
    $entityId = $id > 0 ? 'DEAL_STAGE_' . $id : 'DEAL_STAGE';

    $stages = $sb->getCRMScope()->status()->list(
        ['SORT' => 'ASC'],
        ['ENTITY_ID' => $entityId]
    )->getStatuses();

    foreach ($stages as $item)
    {
        echo $name . ': ' . $item->STATUS_ID . ': ' . $item->NAME
            . ' - ' . ($item->EXTRA['SEMANTICS'] ?? '') . PHP_EOL;
    }
}
// 2. Стадии каждой воронки.
for _, c := range categories.Categories {
	// У воронки по умолчанию идентификатор без суффикса, никогда не DEAL_STAGE_0.
	entityID := "DEAL_STAGE"
	if c.ID > 0 {
		entityID = fmt.Sprintf("DEAL_STAGE_%d", c.ID)
	}

	res, err := client.Core().Call(ctx, "crm.status.list", b24.Params{
		"order":  b24.Params{"SORT": "ASC"},
		"filter": b24.Params{"ENTITY_ID": entityID},
	}, b24.WithIdempotent())
	if err != nil {
		// Воронку, которую вебхуку читать не разрешено, пропускаем:
		// остальные от этого страдать не должны.
		fmt.Fprintf(os.Stderr, "воронка %d (%s): %v\n", c.ID, c.Name, err)
		continue
	}

	var stages []stage
	if err := json.Unmarshal(res.Result, &stages); err != nil {
		return fmt.Errorf("разбор стадий воронки %d: %w", c.ID, err)
	}

	for _, s := range stages {
		fmt.Println(c.Name, s.StatusID, s.Name, s.Extra.Semantics)
	}
}

В ответе метод возвращает массив стадий для указанного ENTITY_ID.

{
    "result": [
        {
            "ENTITY_ID": "DEAL_STAGE_7",
            "STATUS_ID": "NEW",
            "NAME": "Новая",
            "EXTRA": {
                "SEMANTICS": "process",
                "COLOR": "#39A8EF"
            }
        },
        {
            "ENTITY_ID": "DEAL_STAGE_7",
            "STATUS_ID": "WON",
            "NAME": "Сделка успешна",
            "EXTRA": {
                "SEMANTICS": "success",
                "COLOR": "#7BD500"
            }
        },
        {
            "ENTITY_ID": "DEAL_STAGE_7",
            "STATUS_ID": "LOSE",
            "NAME": "Сделка провалена",
            "EXTRA": {
                "SEMANTICS": "failure",
                "COLOR": "#FF5752"
            }
        }
    ],
    "total": 3
}

Поле total показывает общее количество найденных стадий для указанного ENTITY_ID. Метод возвращает одну страницу ответа — до 50 записей. Примеры выше обрабатывают полученные в ответе элементы.

Полный пример кода

Пример выводит таблицу для каждой воронки сделок. В таблице показаны идентификатор стадии, название стадии и семантика.

// npm install @bitrix24/b24jssdk
import { B24Hook } from '@bitrix24/b24jssdk'

const $b24 = B24Hook.fromWebhookUrl('https://your-domain.bitrix24.ru/rest/USER_ID/TOKEN/')

const categoryResponse = await $b24.actions.v2.call.make({
    method: 'crm.category.list',
    params: {
        entityTypeId: 2,
    },
    requestId: 'category-list',
})

if (!categoryResponse.isSuccess) {
    throw new Error(categoryResponse.getErrorMessages().join('; '))
}

const arCategory = categoryResponse.getData().result.categories.reduce((acc, item) => {
    acc[item.id] = item.name
    return acc
}, {})

for (const [id, name] of Object.entries(arCategory)) {
    const entityId = Number(id) > 0 ? `DEAL_STAGE_${id}` : 'DEAL_STAGE'

    const stageResponse = await $b24.actions.v2.call.make({
        method: 'crm.status.list',
        params: {
            order: { SORT: 'ASC' },
            filter: { ENTITY_ID: entityId },
        },
        requestId: `status-list-${id}`,
    })

    if (!stageResponse.isSuccess) {
        console.error(stageResponse.getErrorMessages().join('; '))
        continue
    }

    const table = document.createElement('table')
    const caption = document.createElement('caption')
    caption.textContent = name
    table.appendChild(caption)

    const thead = document.createElement('thead')
    const trHead = document.createElement('tr')
    for (const text of ['STATUS ID', 'NAME', 'SEMANTICS']) {
        const th = document.createElement('th')
        th.textContent = text
        trHead.appendChild(th)
    }
    thead.appendChild(trHead)
    table.appendChild(thead)

    const tbody = document.createElement('tbody')
    for (const item of stageResponse.getData().result) {
        const tr = document.createElement('tr')
        if (item.EXTRA?.COLOR) {
            tr.style.color = item.EXTRA.COLOR
        }
        for (const value of [item.STATUS_ID, item.NAME, item.EXTRA?.SEMANTICS]) {
            const td = document.createElement('td')
            td.textContent = value ?? ''
            tr.appendChild(td)
        }
        tbody.appendChild(tr)
    }
    table.appendChild(tbody)

    document.body.appendChild(table)
}

$b24.destroy()
from b24pysdk import BitrixWebhook, Client
from b24pysdk.errors import BitrixAPIError

client = Client(
    BitrixWebhook(
        domain="your-domain.bitrix24.com",
        webhook_token="user_id/webhook_key",
    )
)

try:
    categories = client.crm.category.list(entity_type_id=2).response.result.get("categories", [])
    category_map = {item["id"]: item["name"] for item in categories}

    for category_id, category_name in category_map.items():
        entity_id = f"DEAL_STAGE_{category_id}" if int(category_id) > 0 else "DEAL_STAGE"
        result_deal = client.crm.status.list(
            order={"SORT": "ASC"},
            filter={"ENTITY_ID": entity_id},
        ).response.result

        print(category_name)
        print("STATUS ID\tNAME\tSEMANTICS")
        for item in result_deal:
            print(
                "\t".join(
                    [
                        str(item.get("STATUS_ID", "")),
                        str(item.get("NAME", "")),
                        str((item.get("EXTRA") or {}).get("SEMANTICS", "")),
                    ]
                )
            )
except BitrixAPIError as error:
    print(error)
<?php
// composer require bitrix24/b24phpsdk:"^3.0"
require_once 'vendor/autoload.php';

use Bitrix24\SDK\Services\ServiceBuilderFactory;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;

$log = new Logger('b24');
$log->pushHandler(new StreamHandler('php://stdout'));

$sb = (new ServiceBuilderFactory(new EventDispatcher(), $log))
    ->initFromWebhook('https://your-domain.bitrix24.ru/rest/USER_ID/TOKEN/');

// crm.category.list не имеет типизированной обертки — вызываем через core
$result = $sb->core->call('crm.category.list', ['entityTypeId' => 2])
    ->getResponseData()
    ->getResult();

$arCategory = array_column($result['categories'] ?? [], 'name', 'id');

foreach ($arCategory as $id => $name):
    $entityId = $id > 0 ? 'DEAL_STAGE_' . $id : 'DEAL_STAGE';

    $stages = $sb->getCRMScope()->status()->list(
        ['SORT' => 'ASC'],
        ['ENTITY_ID' => $entityId]
    )->getStatuses();

    if (!empty($stages)):
?>
        <table>
            <caption><?=htmlspecialchars((string)$name, ENT_QUOTES, 'UTF-8')?></caption>
            <thead>
            <tr>
                <th>STATUS ID</th>
                <th>NAME</th>
                <th>SEMANTICS</th>
            </tr>
            </thead>
            <tbody>
            <?php foreach ($stages as $item): ?>
                <?php
                $statusId = htmlspecialchars((string)($item->STATUS_ID ?? ''), ENT_QUOTES, 'UTF-8');
                $stageName = htmlspecialchars((string)($item->NAME ?? ''), ENT_QUOTES, 'UTF-8');
                $semantics = htmlspecialchars((string)($item->EXTRA['SEMANTICS'] ?? ''), ENT_QUOTES, 'UTF-8');
                $color = (string)($item->EXTRA['COLOR'] ?? '');
                $colorStyle = preg_match('/^#[0-9A-Fa-f]{6}$/', $color) ? ' style="color:' . $color . '"' : '';
                ?>
            <tr<?=$colorStyle?>>
                <td><?=$statusId?></td>
                <td><?=$stageName?></td>
                <td><?=$semantics?></td>
            </tr>
                <?php endforeach; ?>
            </tbody>
        </table>
    <?php endif; ?>
<?php endforeach; ?>
// Подготовка в пустом каталоге — go get без go mod init не сработает:
//   go mod init example && go get github.com/bitrix24/b24gosdk
// Запуск:
//   export B24_WEBHOOK_URL='https://ваш-портал.bitrix24.ru/rest/1/токен/' && go run .
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"os"

	b24 "github.com/bitrix24/b24gosdk"
)

func main() {
	// Путь вебхука — это секрет, поэтому он приходит из окружения, а не из кода.
	if err := run(context.Background()); err != nil {
		log.Fatal(err)
	}
}

func run(ctx context.Context) error {
	client := b24.NewClient(os.Getenv("B24_WEBHOOK_URL"))

	// 1. Список воронок сделок.
	res, err := client.Core().Call(ctx, "crm.category.list", b24.Params{
		"entityTypeId": 2,
	}, b24.WithIdempotent())
	if err != nil {
		return fmt.Errorf("crm.category.list: %w", err)
	}

	// Метод заворачивает ответ в объект с ключом categories.
	var categories struct {
		Categories []struct {
			ID   int    `json:"id"`
			Name string `json:"name"`
		} `json:"categories"`
	}
	if err := json.Unmarshal(res.Result, &categories); err != nil {
		return fmt.Errorf("разбор воронок: %w", err)
	}

	// 2. Стадии каждой воронки.
	for _, c := range categories.Categories {
		// У воронки по умолчанию идентификатор без суффикса, никогда не DEAL_STAGE_0.
		entityID := "DEAL_STAGE"
		if c.ID > 0 {
			entityID = fmt.Sprintf("DEAL_STAGE_%d", c.ID)
		}

		res, err := client.Core().Call(ctx, "crm.status.list", b24.Params{
			"order":  b24.Params{"SORT": "ASC"},
			"filter": b24.Params{"ENTITY_ID": entityID},
		}, b24.WithIdempotent())
		if err != nil {
			// Воронку, которую вебхуку читать не разрешено, пропускаем:
			// остальные от этого страдать не должны.
			fmt.Fprintf(os.Stderr, "воронка %d (%s): %v\n", c.ID, c.Name, err)
			continue
		}

		var stages []stage
		if err := json.Unmarshal(res.Result, &stages); err != nil {
			return fmt.Errorf("разбор стадий воронки %d: %w", c.ID, err)
		}

		for _, s := range stages {
			fmt.Println(c.Name, s.StatusID, s.Name, s.Extra.Semantics)
		}
	}
	return nil
}

// stage — одна строка ответа crm.status.list.
type stage struct {
	StatusID string `json:"STATUS_ID"`
	Name     string `json:"NAME"`

	// У строки есть и ВЕРХНЕУРОВНЕВОЕ поле SEMANTICS, но для стадий сделок оно
	// приходит пустым: настоящее значение лежит в EXTRA. Чтение верхнего поля —
	// обычный способ решить, что у портала семантики нет вовсе.
	Extra struct {
		Semantics string `json:"SEMANTICS"`
	} `json:"EXTRA"`
}

Если результат пустой или возникла ошибка

Если crm.category.list или crm.status.list вернул ошибку, проверьте авторизацию и права пользователя. Для работы сценария нужен доступ к CRM и scope crm.

Если crm.category.list вернул пустой массив categories, пользователь не видит доступные воронки сделок. Проверьте права пользователя на чтение CRM и повторите сценарий с первого шага.

Если crm.status.list вернул пустой массив стадий, проверьте значение ENTITY_ID:

  • для основной воронки с id = 0 передавайте DEAL_STAGE
  • для дополнительной воронки с id > 0 передавайте DEAL_STAGE_{id}
  • не передавайте DEAL_STAGE_0

После исправления ENTITY_ID повторите второй шаг для этой воронки.

Если в ответе есть поле total, но обработаны не все элементы, учтите ограничение одной страницы ответа. Примеры в туториале обрабатывают только элементы, полученные в текущем ответе.

Проверяем результат

После запуска примера на странице появятся таблицы по полученным воронкам сделок. Заголовок таблицы — название воронки. В строках таблицы отображаются стадии этой воронки:

  • STATUS ID — код стадии, который можно использовать в полях и фильтрах сделок
  • NAME — название стадии в интерфейсе CRM
  • SEMANTICS — группа стадии: process, success, failure или apology

Если в Битрикс24 есть только основная воронка, пример выведет одну таблицу. Если созданы дополнительные воронки, для каждой из них будет своя таблица.

Основная воронка имеет id = 0. Для нее код стадий — DEAL_STAGE, без суффикса _0.

Продолжите изучение