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

Битрикс24: массовое завершение дел при перемещении сделок

Автоматическое закрытие всех активных задач и дел при перемещении сделок между этапами воронки. Пакетная обработка с контролем ошибок.

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

Закрытие квартала в отделе продаж выглядит так: три сотни зависших сделок нужно перевести в «Отказ», а у каждой висят незакрытые дела — звонки, встречи, задачи. Руками это неделя работы менеджера, поэтому задачу отдают разработчику.

Скрипт пишется быстро. Опасность здесь не в коде, а в том, что он необратимо меняет данные в CRM — а CRM это то место, где восстановление «как было» никого не устроит. Разберём и сам скрипт, и правила безопасного массового изменения.

Задача

При массовом перемещении сделок в этапы «Отказ» или «Успешно реализовано» необходимо автоматически завершать все незакрытые дела менеджеров по этим сделкам.

Типичные сценарии:

  • Закрытие квартала — перемещение зависших сделок
  • Смена менеджера — завершение его активных задач
  • Очистка воронки от старых сделок

Базовое решение

php
<?php
use Bitrix\Main\Loader;
use Bitrix\Crm\DealTable;
use Bitrix\Crm\ActivityTable;

Loader::includeModule('crm');

// Параметры выборки сделок
$sourceStageId = 'C1:NEW';      // Откуда перемещаем
$targetStageId = 'C1:LOSE';     // Куда перемещаем (Отказ)
$categoryId = 0;                 // ID воронки (0 = основная)

// Получаем сделки для обработки
$deals = DealTable::getList([
    'order' => ['ID' => 'DESC'],
    'filter' => [
        'CATEGORY_ID' => $categoryId,
        'STAGE_ID' => $sourceStageId,
    ],
    'select' => ['ID', 'ASSIGNED_BY_ID', 'TITLE'],
])->fetchAll();

$movedDealIds = [];
$errors = [];

// Перемещаем сделки
foreach ($deals as $deal) {
    $result = DealTable::update($deal['ID'], [
        'STAGE_ID' => $targetStageId,
    ]);
    
    if ($result->isSuccess()) {
        $movedDealIds[] = $deal['ID'];
        echo "✓ Сделка #{$deal['ID']} перемещена\n";
    } else {
        $errors[] = "Сделка #{$deal['ID']}: " . implode(', ', $result->getErrorMessages());
    }
}

// Завершаем активные дела
if (!empty($movedDealIds)) {
    $activities = ActivityTable::getList([
        'filter' => [
            'OWNER_TYPE_ID' => \CCrmOwnerType::Deal,
            'OWNER_ID' => $movedDealIds,
            'COMPLETED' => 'N',
        ],
        'select' => ['ID'],
    ])->fetchAll();
    
    foreach ($activities as $activity) {
        \CCrmActivity::Update($activity['ID'], [
            'COMPLETED' => 'Y',
        ], false, false);
        
        echo "✓ Дело #{$activity['ID']} завершено\n";
    }
}

echo "\nИтого: " . count($movedDealIds) . " сделок перемещено\n";
echo "Завершено дел: " . count($activities) . "\n";

if (!empty($errors)) {
    echo "\nОшибки:\n" . implode("\n", $errors);
}
⚠️ Важно

Перед первым запуском проверьте два предположения, которые в коде выглядят безобидно.

Идентификаторы стадий у вас другие. 'C1:NEW' и 'C1:LOSE' — значения из примера. Реальные зависят от воронки и её настроек: у второй воронки префикс будет C2:, а пользовательские стадии имеют вид C1:UC_XXXXXX. Ошибка в идентификаторе не даст ошибки выполнения — скрипт просто отработает не на тех сделках или ни на каких.

Смена стадии запускает автоматизацию. Роботы, триггеры и бизнес-процессы, настроенные на переход в «Отказ», сработают для каждой сделки. Триста сделок — это триста уведомлений менеджерам, а если на стадии висит робот с письмом клиенту — триста писем клиентам. Проверьте настройки автоматизации целевой стадии до запуска, а не после.

Расширенный класс для массовых операций

php
<?php

namespace Local\Crm;

use Bitrix\Main\Loader;
use Bitrix\Crm\DealTable;
use Bitrix\Crm\ActivityTable;

class DealBulkOperations
{
    private array $stats = [
        'deals_moved' => 0,
        'deals_failed' => 0,
        'activities_completed' => 0,
        'errors' => [],
    ];

    public function __construct()
    {
        if (!Loader::includeModule('crm')) {
            throw new \RuntimeException('Модуль CRM не установлен');
        }
    }

    /**
     * Перемещение сделок с завершением дел
     */
    public function moveDealsWithCompletion(
        string $sourceStage,
        string $targetStage,
        int $categoryId = 0,
        array $additionalFilter = [],
        bool $dryRun = false
    ): array {
        $this->resetStats();

        // Формируем фильтр
        $filter = array_merge([
            'CATEGORY_ID' => $categoryId,
            'STAGE_ID' => $sourceStage,
        ], $additionalFilter);

        $deals = $this->getDeals($filter);

        if (empty($deals)) {
            return $this->stats;
        }

        if ($dryRun) {
            $this->stats['deals_moved'] = count($deals);
            $this->stats['dry_run'] = true;
            return $this->stats;
        }

        $movedDealIds = $this->moveDeals($deals, $targetStage);
        
        if (!empty($movedDealIds)) {
            $this->completeActivities($movedDealIds);
        }

        return $this->stats;
    }

    /**
     * Только завершение дел (без перемещения)
     */
    public function completeActivitiesForDeals(array $dealIds): int
    {
        $this->resetStats();
        $this->completeActivities($dealIds);
        return $this->stats['activities_completed'];
    }

    /**
     * Завершение дел по фильтру сделок
     */
    public function completeActivitiesByDealFilter(array $filter): int
    {
        $this->resetStats();
        
        $deals = $this->getDeals($filter);
        $dealIds = array_column($deals, 'ID');
        
        if (!empty($dealIds)) {
            $this->completeActivities($dealIds);
        }
        
        return $this->stats['activities_completed'];
    }

    /**
     * Получение сделок по фильтру
     */
    private function getDeals(array $filter): array
    {
        return DealTable::getList([
            'order' => ['ID' => 'DESC'],
            'filter' => $filter,
            'select' => ['ID', 'ASSIGNED_BY_ID', 'TITLE', 'STAGE_ID'],
        ])->fetchAll();
    }

    /**
     * Перемещение сделок
     */
    private function moveDeals(array $deals, string $targetStage): array
    {
        $movedIds = [];

        foreach ($deals as $deal) {
            $result = DealTable::update($deal['ID'], [
                'STAGE_ID' => $targetStage,
            ]);

            if ($result->isSuccess()) {
                $movedIds[] = $deal['ID'];
                $this->stats['deals_moved']++;
            } else {
                $this->stats['deals_failed']++;
                $this->stats['errors'][] = [
                    'deal_id' => $deal['ID'],
                    'message' => implode(', ', $result->getErrorMessages()),
                ];
            }
        }

        return $movedIds;
    }

    /**
     * Завершение активных дел
     */
    private function completeActivities(array $dealIds): void
    {
        $activities = ActivityTable::getList([
            'filter' => [
                'OWNER_TYPE_ID' => \CCrmOwnerType::Deal,
                'OWNER_ID' => $dealIds,
                'COMPLETED' => 'N',
            ],
            'select' => ['ID', 'OWNER_ID', 'SUBJECT'],
        ])->fetchAll();

        foreach ($activities as $activity) {
            $result = \CCrmActivity::Update(
                $activity['ID'],
                ['COMPLETED' => 'Y'],
                false, // check permissions
                false  // register sonet event
            );

            if ($result) {
                $this->stats['activities_completed']++;
            } else {
                $this->stats['errors'][] = [
                    'activity_id' => $activity['ID'],
                    'message' => 'Не удалось завершить дело',
                ];
            }
        }
    }

    /**
     * Сброс статистики
     */
    private function resetStats(): void
    {
        $this->stats = [
            'deals_moved' => 0,
            'deals_failed' => 0,
            'activities_completed' => 0,
            'errors' => [],
        ];
    }

    /**
     * Получение статистики
     */
    public function getStats(): array
    {
        return $this->stats;
    }
}

Примеры использования

Перемещение старых сделок в отказ

php
<?php
use Local\Crm\DealBulkOperations;

$operations = new DealBulkOperations();

// Сделки старше 90 дней без активности
$stats = $operations->moveDealsWithCompletion(
    'C1:PREPARATION',  // Из этапа "Подготовка"
    'C1:LOSE',         // В этап "Отказ"
    0,                 // Воронка по умолчанию
    [
        '<DATE_MODIFY' => date('d.m.Y', strtotime('-90 days')),
    ]
);

print_r($stats);

Тестовый прогон (без изменений)

php
<?php
$stats = $operations->moveDealsWithCompletion(
    'C1:NEW',
    'C1:LOSE',
    0,
    [],
    true // dry run
);

echo "Будет обработано сделок: {$stats['deals_moved']}\n";

Завершение дел у конкретных сделок

php
<?php
$dealIds = [100, 101, 102, 103];
$completed = $operations->completeActivitiesForDeals($dealIds);

echo "Завершено дел: {$completed}\n";

Типы дел в CRM

ТипКонстантаОписание
ЗвонокTYPE_ID = 2Входящие/исходящие звонки
ВстречаTYPE_ID = 1Личные встречи
ПисьмоTYPE_ID = 4Email-переписка
ЗадачаTYPE_ID = 6Задачи CRM

Консольный скрипт

Создайте /local/scripts/close_stale_deals.php:

php
<?php
$_SERVER['DOCUMENT_ROOT'] = realpath(__DIR__ . '/../../');
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

use Local\Crm\DealBulkOperations;

// Параметры из командной строки
$dryRun = in_array('--dry-run', $argv);
$daysOld = 90;

foreach ($argv as $arg) {
    if (strpos($arg, '--days=') === 0) {
        $daysOld = (int) substr($arg, 7);
    }
}

echo "=== Закрытие старых сделок ===\n";
echo "Старше: {$daysOld} дней\n";
echo "Режим: " . ($dryRun ? "тестовый" : "боевой") . "\n\n";

$operations = new DealBulkOperations();

$stats = $operations->moveDealsWithCompletion(
    'C1:NEW',
    'C1:LOSE',
    0,
    ['<DATE_CREATE' => date('d.m.Y', strtotime("-{$daysOld} days"))],
    $dryRun
);

echo "Результат:\n";
echo "  Сделок перемещено: {$stats['deals_moved']}\n";
echo "  Сделок с ошибками: {$stats['deals_failed']}\n";
echo "  Дел завершено: {$stats['activities_completed']}\n";

if (!empty($stats['errors'])) {
    echo "\nОшибки:\n";
    foreach ($stats['errors'] as $error) {
        echo "  - " . json_encode($error, JSON_UNESCAPED_UNICODE) . "\n";
    }
}

Запуск:

bash
# Тестовый прогон
php /home/bitrix/www/local/scripts/close_stale_deals.php --dry-run --days=90

# Боевой запуск
php /home/bitrix/www/local/scripts/close_stale_deals.php --days=90

Правила массового изменения данных в CRM

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

Dry-run по умолчанию, боевой режим — по явному флагу. Именно в таком порядке. Скрипт, который по умолчанию меняет данные, однажды будет запущен без нужного аргумента.

Dry-run должен показывать не количество, а примеры. «Будет обработано 342 сделки» не даёт возможности заметить ошибку. Список из десяти сделок с их названиями, стадиями и датами — даёт: если среди них окажется вчерашняя сделка на крупную сумму, фильтр составлен неверно.

Сохраните состояние до изменения. Выгрузка ID + STAGE_ID затронутых сделок в CSV перед запуском — минута работы, которая превращает откат из «невозможно» в «запустить обратный скрипт». Полный бэкап портала для этого не нужен и обычно недоступен.

Запускайте порциями. Первый прогон — на десяти сделках, проверка результата глазами в интерфейсе, и только потом остальное. Ошибка в фильтре, найденная на десяти записях, — это неприятность; на трёхстах — инцидент.

Согласуйте с тем, чьи это данные. Руководитель отдела продаж должен знать, что вы делаете и когда. Не ради формальности: он единственный, кто заметит, что «зависшие» сделки на самом деле в работе.

💡 Совет

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

🚀

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

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

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

Комментарии

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