Закрытие квартала в отделе продаж выглядит так: три сотни зависших сделок нужно перевести в «Отказ», а у каждой висят незакрытые дела — звонки, встречи, задачи. Руками это неделя работы менеджера, поэтому задачу отдают разработчику.
Скрипт пишется быстро. Опасность здесь не в коде, а в том, что он необратимо меняет данные в CRM — а CRM это то место, где восстановление «как было» никого не устроит. Разберём и сам скрипт, и правила безопасного массового изменения.
Задача
При массовом перемещении сделок в этапы «Отказ» или «Успешно реализовано» необходимо автоматически завершать все незакрытые дела менеджеров по этим сделкам.
Типичные сценарии:
- Закрытие квартала — перемещение зависших сделок
- Смена менеджера — завершение его активных задач
- Очистка воронки от старых сделок
Базовое решение
<?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
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
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
$stats = $operations->moveDealsWithCompletion(
'C1:NEW',
'C1:LOSE',
0,
[],
true // dry run
);
echo "Будет обработано сделок: {$stats['deals_moved']}\n";Завершение дел у конкретных сделок
<?php
$dealIds = [100, 101, 102, 103];
$completed = $operations->completeActivitiesForDeals($dealIds);
echo "Завершено дел: {$completed}\n";Типы дел в CRM
| Тип | Константа | Описание |
|---|---|---|
| Звонок | TYPE_ID = 2 | Входящие/исходящие звонки |
| Встреча | TYPE_ID = 1 | Личные встречи |
| Письмо | TYPE_ID = 4 | Email-переписка |
| Задача | TYPE_ID = 6 | Задачи CRM |
Консольный скрипт
Создайте /local/scripts/close_stale_deals.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";
}
}Запуск:
# Тестовый прогон
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.