Скрипт для копирования воронки Смарт процесс

php

/**
 * Скрипт: копирование воронки (категории) и стадий тикетинга (Smart Process).
 *
 * Что копируем:
 * - Категория (воронка): NAME, SORT (по умолчанию +5 от исходной)
 * - Стадии (статусы b_crm_status): NAME, NAME_INIT, SORT, COLOR, SYSTEM, SEMANTICS
 *
 * Важно про ID:
 * - Строковый ID стадии (STATUS_ID) пересобирается под новую категорию, но код после ":" сохраняется.
 *   Пример: DT130_130430:NEW -> DT130_<newCategoryId>:NEW
 * - Числовой ID записи статуса (ID) по умолчанию НЕ сохраняем (чтобы не ловить коллизии).
 *   Можно задать $statusIdStart чтобы создать новые статусы с предсказуемыми ID по порядку.
 *
 * СПОСОБ 1: Задать параметры в коде (вверху файла)
 * ================================================
 * Задайте параметры ниже и запустите скрипт через браузер или CLI
 */

// ============================================
// ПАРАМЕТРЫ (задайте здесь значения)
// ============================================
$sourceCategoryId = 130430;        // ОБЯЗАТЕЛЬНО: ID исходной воронки для копирования
$newName = "Еще одна воронка";         // ОБЯЗАТЕЛЬНО: название новой воронки
$entityTypeId = 0;                  // (опционально) если 0, определяется автоматически из категории
$targetCategoryId = 0;              // (опционально) задать ID новой воронки (0 = авто)
$categorySort = null;               // (опционально) SORT новой категории (null = авто)
$statusIdStart = 0;                 // (опционально) стартовый числовой ID для новых статусов (0 = авто)
$dryRun = false;                    // true = показать что будет сделано без записи в БД, false = выполнить
// ============================================

/**
 * СПОСОБ 2: Через CLI с параметрами командной строки
 * ===================================================
 * php local/php_interface/console_copy_ticketing_funnel.php \
 *   --source-category-id=130430 \
 *   --new-name="Новая воронка"
 *
 * СПОСОБ 3: Через браузер с GET параметрами
 * ==========================================
 * /local/php_interface/console_copy_ticketing_funnel.php?source_category_id=130430&new_name=Новая+воронка
 */

// Флаги для обхода проверки авторизации
define("NOT_CHECK_PERMISSIONS", true);
define("STOP_STATISTICS", true);
define("NO_KEEP_STATISTIC", "Y");
define("NO_AGENT_STATISTIC", "Y");
define("DisableEventsCheck", true);

// Подключаем Битрикс
if (!isset($_SERVER['DOCUMENT_ROOT']) || empty($_SERVER['DOCUMENT_ROOT'])) {
	$_SERVER['DOCUMENT_ROOT'] = dirname(dirname(__DIR__));
}
require($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');

use Bitrix\Crm\Model\ItemCategoryTable;
use Bitrix\Crm\StatusTable;
use Bitrix\Main\Loader;
use Bitrix\Main\Type\DateTime;

if (!Loader::includeModule('crm')) {
	die("Модуль crm должен быть подключен
");
}

$isCli = php_sapi_name() === 'cli';
$isWeb = !$isCli;

// Получаем параметры из разных источников (приоритет: CLI > GET/POST > переменные в коде)
if ($isCli) {
	// CLI: парсим аргументы командной строки
	$cliOptions = getopt('', [
		'entity-type-id:',
		'source-category-id:',
		'new-name:',
		'target-category-id::',
		'sort::',
		'status-id-start::',
		'dry-run',
		'help',
	]);
	
	if (isset($cliOptions['help'])) {
		echo "Использование:
";
		echo "  php local/php_interface/console_copy_ticketing_funnel.php --source-category-id=130430 --new-name=\"Новая воронка\"
";
		echo "
Обязательные опции:
";
		echo "  --source-category-id=NNN    ID исходной воронки для копирования
";
		echo "  --new-name=\"...\"            Название новой воронки
";
		echo "
Опциональные опции:
";
		echo "  --entity-type-id=NNN        (если не указан, определяется автоматически)
";
		echo "  --target-category-id=NNN     задать ID новой воронки
";
		echo "  --sort=NNN                  SORT новой воронки
";
		echo "  --status-id-start=NNN       стартовый числовой ID для новых стадий
";
		echo "  --dry-run                  показать, что будет сделано, без записи в БД
";
		exit(0);
	}
	
	// Переопределяем параметры из CLI, если они указаны
	if (!empty($cliOptions['source-category-id'])) {
		$sourceCategoryId = (int)$cliOptions['source-category-id'];
	}
	if (!empty($cliOptions['new-name'])) {
		$newName = trim((string)$cliOptions['new-name']);
	}
	if (!empty($cliOptions['entity-type-id'])) {
		$entityTypeId = (int)$cliOptions['entity-type-id'];
	}
	if (isset($cliOptions['target-category-id'])) {
		$targetCategoryId = (int)$cliOptions['target-category-id'];
	}
	if (isset($cliOptions['sort'])) {
		$categorySort = (int)$cliOptions['sort'];
	}
	if (isset($cliOptions['status-id-start'])) {
		$statusIdStart = (int)$cliOptions['status-id-start'];
	}
	if (isset($cliOptions['dry-run'])) {
		$dryRun = true;
	}
} else {
	// Веб: получаем параметры из GET/POST
	if (isset($_REQUEST['source_category_id']) && $_REQUEST['source_category_id'] > 0) {
		$sourceCategoryId = (int)$_REQUEST['source_category_id'];
	}
	if (isset($_REQUEST['new_name']) && !empty($_REQUEST['new_name'])) {
		$newName = trim((string)$_REQUEST['new_name']);
	}
	if (isset($_REQUEST['entity_type_id']) && $_REQUEST['entity_type_id'] > 0) {
		$entityTypeId = (int)$_REQUEST['entity_type_id'];
	}
	if (isset($_REQUEST['target_category_id']) && $_REQUEST['target_category_id'] > 0) {
		$targetCategoryId = (int)$_REQUEST['target_category_id'];
	}
	if (isset($_REQUEST['sort']) && $_REQUEST['sort'] > 0) {
		$categorySort = (int)$_REQUEST['sort'];
	}
	if (isset($_REQUEST['status_id_start']) && $_REQUEST['status_id_start'] > 0) {
		$statusIdStart = (int)$_REQUEST['status_id_start'];
	}
	if (isset($_REQUEST['dry_run']) && $_REQUEST['dry_run'] == '1') {
		$dryRun = true;
	}
}

// Нормализуем параметры
$sourceCategoryId = (int)$sourceCategoryId;
$newName = trim((string)$newName);
$targetCategoryId = (int)$targetCategoryId;
$statusIdStart = (int)$statusIdStart;

if ($sourceCategoryId <= 0) {
	die("Ошибка: source-category-id должен быть > 0
");
}

if (empty($newName)) {
	die("Ошибка: new-name не может быть пустым
");
}

// Сначала получаем категорию без фильтра по entity-type-id, чтобы определить его автоматически
$sourceCategory = ItemCategoryTable::getRow([
	'filter' => [
		'=ID' => $sourceCategoryId,
	],
]);

if (!$sourceCategory) {
	die("Ошибка: исходная воронка не найдена (CATEGORY_ID={$sourceCategoryId})
");
}

// Определяем entity-type-id автоматически, если не указан
if ($entityTypeId <= 0) {
	$entityTypeId = (int)$sourceCategory['ENTITY_TYPE_ID'];
}

if ($entityTypeId <= 0) {
	die("Ошибка: не удалось определить ENTITY_TYPE_ID из категории {$sourceCategoryId}
");
}

// Проверяем, что категория действительно принадлежит указанному entity-type-id (если был указан)
if ($entityTypeId > 0 && (int)$sourceCategory['ENTITY_TYPE_ID'] !== $entityTypeId) {
	die("Ошибка: категория {$sourceCategoryId} принадлежит ENTITY_TYPE_ID={$sourceCategory['ENTITY_TYPE_ID']}, а не {$entityTypeId}
");
}

if ($categorySort === null) {
	$categorySort = (int)($sourceCategory['SORT'] ?? 500) + 5;
}

// Функция для вывода (поддержка HTML для веб и текста для CLI)
function output($text, $isHtml = false) {
	global $isWeb;
	if ($isWeb && $isHtml) {
		echo $text;
	} else {
		echo strip_tags($text) . "
";
	}
}

if ($isWeb) {
	echo "<!DOCTYPE html><html><head><meta charset='UTF-8'><title>Копирование воронки</title>";
	echo "<style>body{font-family:monospace;padding:20px;} .success{color:green;} .error{color:red;} .info{color:blue;} pre{background:#f5f5f5;padding:10px;border:1px solid #ddd;}</style>";
	echo "</head><body><h1>Копирование воронки тикетинга</h1><pre>";
}

echo "========================================
";
echo "Копирование воронки тикетинга
";
echo "========================================
";
echo "ENTITY_TYPE_ID: {$entityTypeId}
";
echo "SOURCE CATEGORY: #{$sourceCategoryId} \"{$sourceCategory['NAME']}\"
";
echo "NEW NAME: {$newName}
";
echo "NEW SORT: {$categorySort}
";
if ($targetCategoryId > 0) {
	echo "TARGET CATEGORY ID: {$targetCategoryId}
";
}
if ($statusIdStart > 0) {
	echo "STATUS ID START: {$statusIdStart}
";
}
echo "DRY RUN: " . ($dryRun ? 'YES' : 'NO') . "
";
echo "========================================

";

$sourceStatuses = StatusTable::getList([
	'filter' => [
		'=CATEGORY_ID' => $sourceCategoryId,
	],
	'order' => [
		'SORT' => 'ASC',
		'ID' => 'ASC',
	],
])->fetchAll();

if (empty($sourceStatuses)) {
	die("Ошибка: у исходной воронки нет стадий (CATEGORY_ID={$sourceCategoryId})
");
}

echo "Найдено стадий в исходной воронке: " . count($sourceStatuses) . "

";

$newCategoryFields = [
	'ENTITY_TYPE_ID' => $entityTypeId,
	'IS_DEFAULT' => 'N',
	'NAME' => $newName,
	'SORT' => $categorySort,
	'CREATED_DATE' => new DateTime(),
];
if ($targetCategoryId > 0) {
	$newCategoryFields['ID'] = $targetCategoryId;
}

if ($dryRun) {
	$newCategoryId = $targetCategoryId > 0 ? $targetCategoryId : -1;
	echo "[DRY-RUN] Будет создана новая воронка: " . json_encode($newCategoryFields, JSON_UNESCAPED_UNICODE) . "
";
} else {
	/** @var \Bitrix\Main\ORM\Data\AddResult $addCategoryResult */
	$addCategoryResult = call_user_func([ItemCategoryTable::class, 'add'], $newCategoryFields);
	if (!$addCategoryResult->isSuccess()) {
		die("Ошибка создания воронки: " . implode('; ', $addCategoryResult->getErrorMessages()) . "
");
	}
	$newCategoryId = (int)$addCategoryResult->getId();
	echo "✓ Создана новая воронка: #{$newCategoryId}
";
}

// ENTITY_ID для стадий смарт-процесса
$newEntityId = 'DYNAMIC_' . $entityTypeId . '_STAGE_' . $newCategoryId;

// Удаляем автоматически созданные стандартные стадии (Битрикс создает их при создании категории)
if (!$dryRun) {
	$autoCreatedStatuses = StatusTable::getList([
		'filter' => ['ENTITY_ID' => $newEntityId]
	])->fetchAll();
	
	if (!empty($autoCreatedStatuses)) {
		echo "Удаление автоматически созданных стадий (" . count($autoCreatedStatuses) . ")...
";
		foreach ($autoCreatedStatuses as $autoStatus) {
			StatusTable::delete($autoStatus['ID']);
		}
		echo "✓ Удалено автоматически созданных стадий: " . count($autoCreatedStatuses) . "

";
	}
}

echo "
Копирование стадий...
";
echo "Новый ENTITY_ID стадий: {$newEntityId}

";

$statusIdCounter = $statusIdStart > 0 ? $statusIdStart : 0;

// Разделяем стадии на промежуточные и финальные (с SEMANTICS='S' или 'F')
$intermediateStages = [];
$finalStages = [];

foreach ($sourceStatuses as $src) {
	$semantics = trim((string)($src['SEMANTICS'] ?? ''));
	if ($semantics === 'S' || $semantics === 'F') {
		$finalStages[] = $src;
	} else {
		$intermediateStages[] = $src;
	}
}

// Сначала создаем промежуточные стадии, потом финальные
$stagesToCreate = array_merge($intermediateStages, $finalStages);

$created = 0;
$map = [];
foreach ($stagesToCreate as $src) {
	$srcStatusId = (string)($src['STATUS_ID'] ?? '');
	$stageCode = $srcStatusId;
	if (strpos($srcStatusId, ':') !== false) {
		[$_prefix, $stageCode] = explode(':', $srcStatusId, 2);
	}

	// Новый строковый статус-id под новую категорию, но тот же stageCode
	$newPrefix = \CCrmStatus::getDynamicEntityStatusPrefix($entityTypeId, $newCategoryId);
	$newStatusId = $newPrefix . ':' . $stageCode;

	$dst = [
		'ENTITY_ID' => $newEntityId,
		'STATUS_ID' => $newStatusId,
		'NAME' => (string)($src['NAME'] ?? ''),
		'NAME_INIT' => (string)($src['NAME_INIT'] ?? ''),
		'SORT' => (int)($src['SORT'] ?? 10),
		'SYSTEM' => (string)($src['SYSTEM'] ?? 'N'),
		'COLOR' => (string)($src['COLOR'] ?? ''),
		'CATEGORY_ID' => $newCategoryId,
	];
	
	// SEMANTICS добавляем только если он не пустой (пустая строка вызывает ошибку)
	$semantics = trim((string)($src['SEMANTICS'] ?? ''));
	if ($semantics !== '') {
		$dst['SEMANTICS'] = $semantics;
	}

	if ($statusIdCounter > 0) {
		$dst['ID'] = $statusIdCounter++;
	}

	if ($dryRun) {
		$newNumericId = $dst['ID'] ?? 0;
		echo "[DRY-RUN] + стадия: {$src['ID']} / {$srcStatusId} -> {$newNumericId} / {$newStatusId} | {$dst['NAME']}
";
		$map[] = [
			'old_id' => (int)($src['ID'] ?? 0),
			'old_status_id' => $srcStatusId,
			'new_id' => $newNumericId,
			'new_status_id' => $newStatusId,
			'name' => $dst['NAME'],
		];
		$created++;
		continue;
	}

	/** @var \Bitrix\Main\ORM\Data\AddResult $addStatusResult */
	$addStatusResult = call_user_func([StatusTable::class, 'add'], $dst);
	if (!$addStatusResult->isSuccess()) {
		$errors = implode('; ', $addStatusResult->getErrorMessages());
		die("Ошибка создания стадии '{$dst['NAME']}' ({$srcStatusId} -> {$newStatusId}): {$errors}
");
	}

	$newNumericId = (int)$addStatusResult->getId();
	
	// Для финальных стадий (SUCCESS, FAIL) Битрикс автоматически определяет SEMANTICS
	// Не нужно обновлять вручную - Битрикс сам установит правильную семантику
	
	echo "✓ {$src['ID']} / {$srcStatusId} -> {$newNumericId} / {$newStatusId} | {$dst['NAME']}
";
	$map[] = [
		'old_id' => (int)($src['ID'] ?? 0),
		'old_status_id' => $srcStatusId,
		'new_id' => $newNumericId,
		'new_status_id' => $newStatusId,
		'name' => $dst['NAME'],
	];
	$created++;
}

echo "
========================================
";
echo "Готово!
";
echo "Новая воронка: #{$newCategoryId}
";
echo "Создано стадий: {$created}
";
echo "========================================
";

if ($isWeb) {
	echo "</pre>";
	if (!$dryRun) {
		echo "<p class='success'><strong>✓ Воронка успешно создана!</strong></p>";
		echo "<p>ID новой воронки: <strong>#{$newCategoryId}</strong></p>";
		echo "<p>Создано стадий: <strong>{$created}</strong></p>";
	}
	echo "<hr><p><a href='?'>← Вернуться</a></p>";
	echo "</body></html>";
}