Главная/Статьи/Битрикс24: создание сделки из заказа интернет-магазина

Битрикс24: создание сделки из заказа интернет-магазина

Автоматическое создание сделки в CRM при оформлении заказа на сайте 1С-Битрикс. Поиск/создание контакта, привязка товаров.

ДМ
Дмитрий Мещеряков
📅 12 ноября 2022 г.📖 9 мин чтения

Интеграция «заказ на сайте → сделка в Битрикс24» — одна из самых востребованных доработок и одновременно одна из самых аварийных. Причина в том, что она связывает две независимые системы в момент, критичный для бизнеса: оформление заказа.

Дальше — рабочая реализация, но сначала главный вопрос, который нужно решить до написания кода: что произойдёт, если Битрикс24 в этот момент недоступен.

Задача

При оформлении заказа в интернет-магазине на 1С-Битрикс автоматически создавать сделку в Битрикс24:

  1. Найти существующего контакта по телефону/email
  2. Если не найден — создать нового
  3. Создать сделку и привязать к контакту
  4. Добавить товары из корзины в сделку
⚠️ Важно

Заказ не должен зависеть от доступности CRM. Если код создания сделки выполняется синхронно в обработчике оформления заказа, то при недоступности портала (обновление, сбой сети, превышенный лимит) покупатель увидит ошибку и уйдёт. Магазин перестал продавать, потому что недоступна CRM, — ситуация, которую бизнес не простит.

Правильная схема: обработчик события сохраняет задание на синхронизацию (запись в своей таблице), а обмен выполняет фоновый процесс — cron-скрипт или агент. Заказ создаётся всегда; сделка появляется через минуту, и никого это не беспокоит.

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

Подготовка

Создание вебхука в Битрикс24

  1. Перейдите в Разработчикам → Другое → Входящий вебхук
  2. Выберите права доступа:
    • crm — работа с CRM
    • user — информация о пользователях
  3. Скопируйте URL вебхука

URL будет вида: https://your-portal.bitrix24.ru/rest/1/abc123xyz456/

⚠️ Важно

Этот URL целиком является секретом — токен встроен прямо в адрес. Не коммитьте его, не логируйте полный адрес запроса, не пересылайте в мессенджере. Место ему в конфигурации вне репозитория.

И выдавайте вебхуку минимально необходимые права. Для этой задачи нужны crm и user; отмечать всё подряд «чтобы точно работало» — значит превратить утёкший URL из проблемы в катастрофу.

Базовая реализация

Добавьте в /local/php_interface/init.php:

php
<?php

use Bitrix\Main\EventManager;
use Bitrix\Main\Web\HttpClient;

// Регистрация обработчика события
EventManager::getInstance()->addEventHandler(
    'sale',
    'OnSaleOrderSaved',
    'createDealFromOrder'
);

/**
 * Отправка запроса к API Битрикс24
 */
function sendB24Request(string $method, array $params): array
{
    $webhookUrl = 'https://your-portal.bitrix24.ru/rest/1/your-webhook-token/';
    
    $httpClient = new HttpClient([
        'socketTimeout' => 10,
        'streamTimeout' => 30,
    ]);
    
    $response = $httpClient->post($webhookUrl . $method, $params);
    
    if ($httpClient->getStatus() !== 200) {
        AddMessage2Log("B24 API Error: HTTP {$httpClient->getStatus()}", 'b24_integration');
        return [];
    }
    
    return json_decode($response, true) ?? [];
}

/**
 * Создание сделки из заказа
 */
function createDealFromOrder(\Bitrix\Main\Event $event): void
{
    $order = $event->getParameter('ENTITY');
    
    // Обрабатываем только новые заказы
    if (!$event->getParameter('IS_NEW')) {
        return;
    }
    
    try {
        $basket = $order->getBasket();
        $props = $order->getPropertyCollection();
        
        // Получаем данные покупателя
        $name = $props->getPayerName()?->getValue() ?? '';
        $phone = $props->getPhone()?->getValue() ?? '';
        $email = $props->getUserEmail()?->getValue() ?? '';
        
        // Ищем или создаём контакт
        $contactId = findOrCreateContact($name, $phone, $email);
        
        if (!$contactId) {
            throw new \RuntimeException('Не удалось создать контакт');
        }
        
        // Создаём сделку
        $dealId = createDeal($order, $contactId);
        
        if (!$dealId) {
            throw new \RuntimeException('Не удалось создать сделку');
        }
        
        // Добавляем товары
        if ($basket && count($basket->getQuantityList()) > 0) {
            addProductsToDeal($dealId, $basket);
        }
        
        AddMessage2Log("Создана сделка #{$dealId} для заказа #{$order->getId()}", 'b24_integration');
        
    } catch (\Throwable $e) {
        AddMessage2Log("Ошибка создания сделки: {$e->getMessage()}", 'b24_integration');
    }
}

/**
 * Поиск или создание контакта
 */
function findOrCreateContact(string $name, string $phone, string $email): ?int
{
    // Поиск по телефону
    if ($phone) {
        $result = sendB24Request('crm.contact.list.json', [
            'filter' => ['PHONE' => $phone],
            'select' => ['ID'],
        ]);
        
        if (!empty($result['result'][0]['ID'])) {
            return (int) $result['result'][0]['ID'];
        }
    }
    
    // Поиск по email
    if ($email) {
        $result = sendB24Request('crm.contact.list.json', [
            'filter' => ['EMAIL' => $email],
            'select' => ['ID'],
        ]);
        
        if (!empty($result['result'][0]['ID'])) {
            return (int) $result['result'][0]['ID'];
        }
    }
    
    // Создание нового контакта
    $contactFields = [
        'fields' => [
            'NAME' => $name ?: 'Покупатель',
            'OPENED' => 'Y',
            'ASSIGNED_BY_ID' => 1,
            'TYPE_ID' => 'CLIENT',
            'SOURCE_ID' => 'WEB',
        ],
        'params' => ['REGISTER_SONET_EVENT' => 'Y'],
    ];
    
    if ($phone) {
        $contactFields['fields']['PHONE'] = [
            ['VALUE' => $phone, 'VALUE_TYPE' => 'WORK'],
        ];
    }
    
    if ($email) {
        $contactFields['fields']['EMAIL'] = [
            ['VALUE' => $email, 'VALUE_TYPE' => 'WORK'],
        ];
    }
    
    $result = sendB24Request('crm.contact.add.json', $contactFields);
    
    return isset($result['result']) ? (int) $result['result'] : null;
}

/**
 * Создание сделки
 */
function createDeal(\Bitrix\Sale\Order $order, int $contactId): ?int
{
    $dealFields = [
        'fields' => [
            'TITLE' => "Заказ №{$order->getId()} с сайта",
            'CONTACT_ID' => $contactId,
            'OPPORTUNITY' => $order->getPrice(),
            'CURRENCY_ID' => $order->getCurrency(),
            'ASSIGNED_BY_ID' => 1,
            'SOURCE_ID' => 'WEB',
            'STAGE_ID' => 'NEW',
            'COMMENTS' => "Заказ создан автоматически. ID на сайте: {$order->getId()}",
        ],
        'params' => ['REGISTER_SONET_EVENT' => 'Y'],
    ];
    
    $result = sendB24Request('crm.deal.add.json', $dealFields);
    
    return isset($result['result']) ? (int) $result['result'] : null;
}

/**
 * Добавление товаров к сделке
 */
function addProductsToDeal(int $dealId, \Bitrix\Sale\Basket $basket): void
{
    $rows = [];
    
    foreach ($basket as $item) {
        $rows[] = [
            'PRODUCT_NAME' => $item->getField('NAME'),
            'PRICE' => $item->getPrice(),
            'QUANTITY' => $item->getQuantity(),
        ];
    }
    
    sendB24Request('crm.deal.productrows.set', [
        'id' => $dealId,
        'rows' => $rows,
    ]);
}

Расширенная реализация

Вынесем логику в отдельный класс с дополнительными возможностями:

php
<?php

namespace Local\Integration\Bitrix24;

use Bitrix\Main\Web\HttpClient;
use Bitrix\Sale\Order;
use Bitrix\Sale\Basket;

class OrderToDealService
{
    private string $webhookUrl;
    private int $defaultResponsibleId;
    private HttpClient $httpClient;

    public function __construct(string $webhookUrl, int $responsibleId = 1)
    {
        $this->webhookUrl = rtrim($webhookUrl, '/') . '/';
        $this->defaultResponsibleId = $responsibleId;
        $this->httpClient = new HttpClient([
            'socketTimeout' => 10,
            'streamTimeout' => 30,
        ]);
    }

    /**
     * Основной метод — создание сделки из заказа
     */
    public function createFromOrder(Order $order): ?int
    {
        $props = $order->getPropertyCollection();
        
        $customerData = [
            'name' => $this->getPropertyValue($props, 'FIO') 
                   ?? $props->getPayerName()?->getValue() 
                   ?? '',
            'phone' => $this->getPropertyValue($props, 'PHONE')
                    ?? $props->getPhone()?->getValue()
                    ?? '',
            'email' => $this->getPropertyValue($props, 'EMAIL')
                    ?? $props->getUserEmail()?->getValue()
                    ?? '',
            'comment' => $this->getPropertyValue($props, 'COMMENT') ?? '',
        ];

        // Поиск или создание контакта
        $contactId = $this->findContact($customerData['phone'], $customerData['email'])
                  ?? $this->createContact($customerData);

        if (!$contactId) {
            throw new \RuntimeException('Не удалось найти/создать контакт');
        }

        // Создание сделки
        $dealId = $this->createDeal($order, $contactId, $customerData['comment']);

        if (!$dealId) {
            throw new \RuntimeException('Не удалось создать сделку');
        }

        // Добавление товаров
        $basket = $order->getBasket();
        if ($basket) {
            $this->addProducts($dealId, $basket);
        }

        return $dealId;
    }

    /**
     * Поиск контакта по телефону или email
     */
    private function findContact(?string $phone, ?string $email): ?int
    {
        if ($phone) {
            $result = $this->apiCall('crm.contact.list', [
                'filter' => ['PHONE' => $phone],
                'select' => ['ID'],
            ]);
            
            if ($id = $result['result'][0]['ID'] ?? null) {
                return (int) $id;
            }
        }

        if ($email) {
            $result = $this->apiCall('crm.contact.list', [
                'filter' => ['EMAIL' => $email],
                'select' => ['ID'],
            ]);
            
            if ($id = $result['result'][0]['ID'] ?? null) {
                return (int) $id;
            }
        }

        return null;
    }

    /**
     * Создание контакта
     */
    private function createContact(array $data): ?int
    {
        $fields = [
            'NAME' => $data['name'] ?: 'Покупатель',
            'OPENED' => 'Y',
            'ASSIGNED_BY_ID' => $this->defaultResponsibleId,
            'TYPE_ID' => 'CLIENT',
            'SOURCE_ID' => 'WEB',
        ];

        if (!empty($data['phone'])) {
            $fields['PHONE'] = [['VALUE' => $data['phone'], 'VALUE_TYPE' => 'MOBILE']];
        }

        if (!empty($data['email'])) {
            $fields['EMAIL'] = [['VALUE' => $data['email'], 'VALUE_TYPE' => 'WORK']];
        }

        $result = $this->apiCall('crm.contact.add', [
            'fields' => $fields,
            'params' => ['REGISTER_SONET_EVENT' => 'Y'],
        ]);

        return isset($result['result']) ? (int) $result['result'] : null;
    }

    /**
     * Создание сделки
     */
    private function createDeal(Order $order, int $contactId, string $comment = ''): ?int
    {
        $fields = [
            'TITLE' => "Заказ №{$order->getId()} с сайта",
            'CONTACT_ID' => $contactId,
            'OPPORTUNITY' => $order->getPrice(),
            'CURRENCY_ID' => $order->getCurrency(),
            'ASSIGNED_BY_ID' => $this->defaultResponsibleId,
            'SOURCE_ID' => 'WEB',
            'STAGE_ID' => 'NEW',
            'COMMENTS' => $comment,
            // Пользовательские поля (если настроены)
            // 'UF_CRM_ORDER_ID' => $order->getId(),
        ];

        $result = $this->apiCall('crm.deal.add', [
            'fields' => $fields,
            'params' => ['REGISTER_SONET_EVENT' => 'Y'],
        ]);

        return isset($result['result']) ? (int) $result['result'] : null;
    }

    /**
     * Добавление товаров к сделке
     */
    private function addProducts(int $dealId, Basket $basket): void
    {
        $rows = [];

        foreach ($basket as $item) {
            $rows[] = [
                'PRODUCT_NAME' => $item->getField('NAME'),
                'PRICE' => $item->getPrice(),
                'QUANTITY' => $item->getQuantity(),
                'DISCOUNT_TYPE_ID' => 2, // Процентная скидка
                'DISCOUNT_RATE' => $item->getDiscountPrice() > 0 
                    ? round($item->getDiscountPrice() / $item->getBasePrice() * 100, 2) 
                    : 0,
            ];
        }

        if (!empty($rows)) {
            $this->apiCall('crm.deal.productrows.set', [
                'id' => $dealId,
                'rows' => $rows,
            ]);
        }
    }

    /**
     * Вызов API Битрикс24
     */
    private function apiCall(string $method, array $params = []): array
    {
        $url = $this->webhookUrl . $method . '.json';
        $response = $this->httpClient->post($url, $params);

        if ($this->httpClient->getStatus() !== 200) {
            throw new \RuntimeException(
                "B24 API Error: HTTP {$this->httpClient->getStatus()}"
            );
        }

        $data = json_decode($response, true);

        if (isset($data['error'])) {
            throw new \RuntimeException(
                "B24 API Error: {$data['error']} - {$data['error_description']}"
            );
        }

        return $data;
    }

    /**
     * Получение значения свойства заказа по коду
     */
    private function getPropertyValue($props, string $code): ?string
    {
        foreach ($props as $prop) {
            if ($prop->getField('CODE') === $code) {
                return $prop->getValue();
            }
        }
        return null;
    }
}

Регистрация обработчика

php
<?php
// /local/php_interface/init.php

use Bitrix\Main\EventManager;
use Local\Integration\Bitrix24\OrderToDealService;

EventManager::getInstance()->addEventHandler(
    'sale',
    'OnSaleOrderSaved',
    function (\Bitrix\Main\Event $event) {
        if (!$event->getParameter('IS_NEW')) {
            return;
        }
        
        $order = $event->getParameter('ENTITY');
        
        try {
            $service = new OrderToDealService(
                'https://your-portal.bitrix24.ru/rest/1/your-token/',
                8 // ID ответственного менеджера
            );
            
            $dealId = $service->createFromOrder($order);
            
            AddMessage2Log(
                "Создана сделка #{$dealId} для заказа #{$order->getId()}",
                'b24_integration'
            );
            
        } catch (\Throwable $e) {
            AddMessage2Log(
                "Ошибка интеграции с B24: {$e->getMessage()}",
                'b24_integration'
            );
        }
    }
);

Таблица методов API

МетодОписание
crm.contact.listПоиск контактов
crm.contact.addСоздание контакта
crm.deal.addСоздание сделки
crm.deal.productrows.setДобавление товаров к сделке
crm.deal.updateОбновление сделки

Дубли: главная проблема этой интеграции

Поиск контакта по телефону перед созданием нового — правильная идея, которая работает хуже, чем ожидается. Разберём почему.

Телефон приходит в разном формате. +7 (999) 123-45-67, 89991234567, 79991234567 — для человека это один номер, для поиска по точному совпадению три разных. Нормализуйте номер перед поиском и перед сохранением: оставьте цифры, приведите к единому виду (7XXXXXXXXXX). Без этого каждый второй заказ создаёт нового контакта, и через год в CRM три карточки на одного клиента.

Заказ может отправиться дважды. Повторный запуск фонового обмена, ручной перезапуск после ошибки, событие, сработавшее второй раз, — и появляются две одинаковые сделки. Лечится отметкой на стороне сайта: в заказе хранится ID созданной сделки, и повторная синхронизация видит, что она уже есть.

Гонка при одновременных заказах. Два заказа от одного клиента в один момент: оба процесса ищут контакт, оба не находят, оба создают. Здесь помогает та же отметка плюс обработка на стороне обмена в один поток.

Лимиты и batch

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

Метод batch объединяет до 50 команд в один запрос и умеет подставлять результат предыдущей команды в следующую ($result[cmd_name]) — то есть «создать контакт и сразу привязать к нему сделку» делается одним обращением. Для регулярной выгрузки заказов это разница между «укладываемся» и «упираемся в лимит».

Итоги

Обмен — асинхронный. Оформление заказа не должно зависеть от доступности портала.

Телефон нормализуется перед поиском и перед записью. Это основная причина дублей контактов.

В заказе хранится ID созданной сделки — защита от повторной отправки и точка входа для диагностики.

Логируйте обмен, но без секретов. Лог нужен, чтобы отвечать на вопрос «а заказ точно ушёл?»; полный URL вебхука в него попадать не должен.

Нужен мониторинг. Молча сломавшаяся интеграция — это заказы, о которых отдел продаж не знает. Проверка «за последний час есть успешные синхронизации, необработанных заданий не больше N» с уведомлением обязательна.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.