惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Schneier on Security
The Register - Security
The Register - Security
月光博客
月光博客
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The GitHub Blog
The GitHub Blog
博客园 - 司徒正美
罗磊的独立博客
U
Unit 42
S
SegmentFault 最新的问题
Y
Y Combinator Blog
博客园_首页
Hugging Face - Blog
Hugging Face - Blog
J
Java Code Geeks
Schneier on Security
Schneier on Security
Know Your Adversary
Know Your Adversary
C
Check Point Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Simon Willison's Weblog
Simon Willison's Weblog
V
Vulnerabilities – Threatpost
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
阮一峰的网络日志
阮一峰的网络日志
The Hacker News
The Hacker News
博客园 - 叶小钗
C
Cybersecurity and Infrastructure Security Agency CISA
Spread Privacy
Spread Privacy
L
LINUX DO - 热门话题
T
The Exploit Database - CXSecurity.com
P
Palo Alto Networks Blog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Latest news
Latest news
L
Lohrmann on Cybersecurity
A
About on SuperTechFans
L
LangChain Blog
Stack Overflow Blog
Stack Overflow Blog
S
Securelist
A
Arctic Wolf
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Threatpost
Scott Helme
Scott Helme
博客园 - 聂微东
博客园 - 【当耐特】
T
Tenable Blog
I
Intezer
D
DataBreaches.Net
B
Blog RSS Feed
Security Latest
Security Latest
C
Cisco Blogs
T
Tor Project blog
N
Netflix TechBlog - Medium

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Migration toolkit для 1С Битрикс: переносим аккаунт между инстансами через crm.*.list + идемпотентность по ORIGINATOR_ID
yakov_etern8 · 2026-05-05 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение13 мин

Охват и читатели9.5K

Кейс

Зачем переносить аккаунт Битрикс между инстансами

В предыдущей статье (как отдавать лиды из Next.js в 1С Битрикс) я показывал outbound‑интеграцию: сайт пишет лид к себе в PostgreSQL, через after() отдаёт его в Битрикс, в строку лида подкладывает bitrix_id. Архитектура работает, пока Битрикс один.

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

  • Переезд серверов. Клиент держал self‑hosted Битрикс на старом VPS, переезжает на новый. SaaS‑инстанс на новый домен — то же самое.

  • Разделение test/prod. Команда работала в одном продакшн‑аккаунте. Хотят отдельный staging, в который скопирован срез реальных данных, чтобы тестировать без риска для живой воронки.

  • Разделение юрлиц. Компания делится на два юридических лица, каждому нужен свой Битрикс с частью общей клиентской базы.

  • Тестовый контур интеграции. Я как разработчик не могу гонять интеграцию по живой CRM клиента. Мне нужен инстанс с зеркалом данных — чтобы отлаживать синхронизатор без риска налажать на проде.

Во всех четырёх случаях задача одна: перенести лиды, сделки, контакты, компании из source‑инстанса в target‑инстанс, не плодя дубли при повторных прогонах. То есть не просто скрипт «один раз залить и забыть», а инструмент, который можно запускать 5 раз — и пятый раз он не создаст ещё пять копий каждого лида.

В этой статье — паттерн migration toolkit, который мы используем на проекте маркетплейса недвижимости. Один Node‑скрипт, два webhook URL в env‑переменных, никаких очередей и отдельной БД. Идемпотентность держится через ORIGINATOR_ID + ORIGIN_ID — это и есть главное, что отличает migration toolkit от наивного «слил‑залил».


Архитектура: один скрипт, два webhook

Migration toolkit не нужен как сервис. Это разовый CLI‑инструмент, который запускается оператором руками, сверяется с логом и при необходимости прогоняется повторно.

┌────────────────────┐                              ┌────────────────────┐
│  BITRIX_SOURCE     │  ── crm.lead.list ─────▶     │   migrate.ts       │
│  (откуда тянем)    │     crm.deal.list            │   - pagination     │
│                    │     crm.contact.list         │   - normalization  │
│                    │     crm.company.list         │   - mapping        │
└────────────────────┘                              │                    │
                                                    │                    │
┌────────────────────┐                              │                    │
│  BITRIX_TARGET     │  ◀── crm.lead.add ────       │                    │
│  (куда пишем)      │      crm.deal.add            │                    │
│                    │      crm.contact.add         │                    │
│                    │      crm.company.add         │                    │
└────────────────────┘                              └────────────────────┘
                                                            │
                                                            ▼
                                                   ┌─────────────────┐
                                                   │  migration.log  │
                                                   │  (плоский JSON) │
                                                   └─────────────────┘

Что в этой схеме намеренно отсутствует:

  • Нет промежуточной БД. Данные source‑инстанса читаются батчами в память (по 50 записей), мапятся, отправляются в target и забываются. Если процесс упадёт — перезапускаем, идемпотентность защитит от дублей.

  • Нет воркеров и очередей. Это разовая операция, не daemon. Redis/BullMQ тут — over‑engineering. Если миграция занимает 6 часов — пусть скрипт работает 6 часов в screen/tmux.

  • Нет двусторонней синхронизации. Это miграция, а не sync. Source → target, в одну сторону. После миграции source выключается или используется только как архив.

Конфиг — две переменные окружения:

# .env
BITRIX_SOURCE_WEBHOOK_URL=https://old-account.bitrix24.ru/rest/1/abc123def456/
BITRIX_TARGET_WEBHOOK_URL=https://new-account.bitrix24.ru/rest/1/xyz789ghi012/

Webhook вместо OAuth‑приложения по тем же причинам, что и в outbound‑сценарии: не нужен Marketplace‑апрув, скоупы задаются в админке Битрикса при создании вебхука, токен хранится как обычный env. Минус — токен в URL, поэтому правило: ни в логи, ни в Sentry полный URL не пишется. Только название метода и payload.


Чтение source: crm.*.list + пагинация

Битрикс отдаёт данные через семейство методов crm.{entity}.list. Базовый вызов:

// migrate-toolkit/src/source.ts
import { bitrixRequest } from "./client";

const PAGE_SIZE = 50; // Жёсткий лимит Bitrix24, больше нельзя

export async function* iterateLeads(): AsyncGenerator<BitrixLead> {
  let start = 0;

  while (true) {
    const response = await bitrixRequest("source", "crm.lead.list", {
      start,
      order: { ID: "ASC" },
      filter: {}, // без фильтра — тянем всё
      select: ["*", "UF_*"], // включая пользовательские поля
    });

    const leads: BitrixLead[] = response.result ?? [];
    for (const lead of leads) {
      yield lead;
    }

    // Битрикс возвращает next в виде смещения для следующей страницы
    if (response.next === undefined || leads.length < PAGE_SIZE) {
      return;
    }
    start = response.next;
  }
}

Три момента, на которые натыкаются почти все:

  1. start — это смещение, не номер страницы. Если на странице 50 записей и вы прочитали 10 страниц — следующий start = 500. Битрикс отдаёт это значение в response.next, и проще доверять ему, чем считать самому.

  2. Лимит 50 записей на страницу — жёсткий. Можно попросить меньше через ?limit=20, но больше — нет, отрежет молча. Я держу PAGE_SIZE = 50 константой и не трогаю.

  3. select: ["*", "UF_*"] — нужно явно просить пользовательские поля. По умолчанию crm.lead.list отдаёт только системные поля, и все ваши UF_CRM_* (адреса домов, кастомные статусы, ссылки на объекты) останутся за бортом. Это самая частая ошибка миграции — мигрировали лиды без половины важных полей и заметили через неделю.

Аналогично работают crm.deal.list, crm.contact.list, crm.company.list — единый паттерн пагинации.

Retry на rate limit

У Битрикса есть лимит ~2 запросов в секунду на webhook (на самом деле сложнее, там скользящее окно, но ориентируйтесь на 2 RPS). При миграции базы на 50 000 записей это означает минимум 50 000 / 50 / 2 = 500 секунд чтения. На практике дольше из‑за сетевых задержек.

Если упереться в лимит — Битрикс возвращает error: QUERY_LIMIT_EXCEEDED. Минимальный клиент с retry на этот случай:

// migrate-toolkit/src/client.ts
const MAX_ATTEMPTS = 4;
const REQUEST_TIMEOUT_MS = 15_000; // больше, чем для outbound — list тяжелее add
const BASE_DELAY_MS = 600;

type Side = "source" | "target";

const URLS: Record<Side, string> = {
  source: process.env.BITRIX_SOURCE_WEBHOOK_URL!,
  target: process.env.BITRIX_TARGET_WEBHOOK_URL!,
};

export async function bitrixRequest(
  side: Side,
  method: string,
  payload: Record<string, unknown>
) {
  const url = `${URLS[side]}${method}.json`;

  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);

    try {
      const response = await fetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
        signal: controller.signal,
      });

      const text = await response.text();
      const json = text ? JSON.parse(text) : {};

      // Rate limit — ждём и повторяем
      if (json.error === "QUERY_LIMIT_EXCEEDED") {
        await sleep(BASE_DELAY_MS * attempt * 2);
        continue;
      }

      if (!response.ok || json.error) {
        throw new Error(
          json.error_description || json.error || `HTTP ${response.status}`
        );
      }

      return json;
    } catch (error) {
      if (attempt >= MAX_ATTEMPTS) throw error;
      await sleep(BASE_DELAY_MS * attempt);
    } finally {
      clearTimeout(timer);
    }
  }

  throw new Error(`${method}: retries exhausted`);
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

Линейный backoff, четыре попытки, отдельная ветка под QUERY_LIMIT_EXCEEDED с увеличенной паузой. Большего на разовой миграции не нужно — экспоненциальный backoff с jitter и circuit breaker оставьте для прод‑сервисов.


Идемпотентность: ORIGINATOR_ID + ORIGIN_ID как нативный ключ

Это центральная часть статьи. Если в migration toolkit нет идемпотентности — он не migration toolkit, а скрипт «слей‑залей».

Битрикс из коробки даёт два поля для пометки записей, пришедших из внешнего источника:

  • ORIGINATOR_ID — идентификатор системы‑источника. Я кладу сюда хеш URL source‑вебхука или просто понятный лейбл вроде bitrix-old-prod.

  • ORIGIN_ID — идентификатор записи в системе‑источнике. Сюда кладу строковый ID лида/сделки/контакта из source‑инстанса.

При создании записи через crm.lead.add и аналоги Битрикс сам проверяет, нет ли уже в target‑инстансе записи с такой парой (ORIGINATOR_ID, ORIGIN_ID). Если есть — ничего не создаётся, возвращается ID существующей записи. Это нативный механизм Битрикса, не наш велосипед.

Вот как это выглядит в коде:

// migrate-toolkit/src/leads.ts
import { bitrixRequest } from "./client";
import { iterateLeads } from "./source";
import { mapLead } from "./mapping";

const ORIGINATOR_ID = "bitrix-old-prod"; // фиксируем на весь прогон

export async function migrateLeads() {
  let migrated = 0;
  let skipped = 0;

  for await (const sourceLead of iterateLeads()) {
    const targetPayload = await mapLead(sourceLead);

    const response = await bitrixRequest("target", "crm.lead.add", {
      fields: {
        ...targetPayload,
        ORIGINATOR_ID,
        ORIGIN_ID: String(sourceLead.ID),
      },
      params: { REGISTER_SONET_EVENT: "N" }, // не плодим события в ленте
    });

    if (response.result) {
      migrated++;
      logSuccess(sourceLead.ID, response.result);
    } else {
      skipped++;
      logSkip(sourceLead.ID, response.error);
    }
  }

  console.log(`Leads: migrated=${migrated}, skipped=${skipped}`);
}

При повторном запуске того же скрипта — Битрикс увидит, что лид с ORIGINATOR_ID = "bitrix-old-prod" и ORIGIN_ID = "12345" уже создан, и не плодит дубль. Это работает на четырёх основных сущностях: crm.lead.add, crm.deal.add, crm.contact.add, crm.company.add.

Два важных момента, на которых ловятся:

  1. ORIGINATOR_ID — строка, и она должна быть стабильной между прогонами. Если первый раз вы записали ORIGINATOR_ID = "old-bitrix", а второй раз — ORIGINATOR_ID = "bitrix-old-prod", то для Битрикса это разные источники, и он создаст дубли. Я фиксирую константу в коде и не трогаю до конца миграции.

  2. params: { REGISTER_SONET_EVENT: "N" } — без этого каждый созданный лид породит запись в живой ленте Битрикса. После миграции 50 000 лидов лента превратится в нечитаемое полотно. У менеджеров будет сердечный приступ. Этот флаг я ставлю на все миграционные *.add‑вызовы.


Маппинг сложных полей

Простые поля (имя, телефон, email) переносятся как есть. Сложности начинаются на трёх типах полей.

Enum‑значения (статусы, типы, источники)

В Битриксе статус лида хранится как ID элемента справочника, например STATUS_ID: "NEW" или SOURCE_ID: "5". Проблема в том, что ID статусов в source и target могут не совпадать, особенно если справочники в target правились руками.

Поэтому первый шаг миграции — вытянуть оба справочника через crm.status.list и построить маппинг по символьным значениям:

// migrate-toolkit/src/mapping/status.ts
import { bitrixRequest } from "../client";

type StatusEntity = "STATUS" | "SOURCE" | "DEAL_STAGE";

let cache: Map<string, Map<string, string>> | null = null;

async function buildMap(entity: StatusEntity) {
  const sourceList = await bitrixRequest("source", "crm.status.list", {
    filter: { ENTITY_ID: entity },
  });
  const targetList = await bitrixRequest("target", "crm.status.list", {
    filter: { ENTITY_ID: entity },
  });

  // Ключ — пара (STATUS_ID, NAME). Сначала пробуем по STATUS_ID, потом по NAME.
  const targetByStatusId = new Map<string, string>();
  const targetByName = new Map<string, string>();
  for (const item of targetList.result) {
    targetByStatusId.set(item.STATUS_ID, item.STATUS_ID);
    targetByName.set(item.NAME.trim().toLowerCase(), item.STATUS_ID);
  }

  const map = new Map<string, string>();
  for (const item of sourceList.result) {
    const sourceId = item.STATUS_ID;
    const matchById = targetByStatusId.get(sourceId);
    const matchByName = targetByName.get(item.NAME.trim().toLowerCase());
    const targetId = matchById ?? matchByName;
    if (targetId) {
      map.set(sourceId, targetId);
    }
  }

  return map;
}

export async function getStatusMap(entity: StatusEntity) {
  if (!cache) cache = new Map();
  let map = cache.get(entity);
  if (!map) {
    map = await buildMap(entity);
    cache.set(entity, map);
  }
  return map;
}

Логика двухступенчатая: сначала пробуем найти статус по STATUS_ID (если справочники совпадают — попадаем сразу), потом по нормализованному NAME (если ID отличаются — выручают человекочитаемые названия). Если ни то, ни другое не сработало — статус остаётся пустым в target, и это пишется в migration.log для ручного разбора.

То же самое работает для SOURCE_ID, стадий сделок (DEAL_STAGE), типов компании, типов контакта.

Пользователи (ответственные)

Поле ASSIGNED_BY_ID хранит ID пользователя Битрикса, на которого назначен лид. ID в source и target почти гарантированно разные, потому что пользователи добавляются в каждый аккаунт независимо.

Маппинг строится по email — это единственное поле, которое стабильно совпадает у одного и того же человека в двух инстансах:

// migrate-toolkit/src/mapping/user.ts
const FALLBACK_USER_ID = "1"; // обычно админ

export async function buildUserMap() {
  const sourceUsers = await fetchAllUsers("source");
  const targetUsers = await fetchAllUsers("target");

  const targetByEmail = new Map<string, string>();
  for (const u of targetUsers) {
    if (u.EMAIL) targetByEmail.set(u.EMAIL.trim().toLowerCase(), u.ID);
  }

  const map = new Map<string, string>();
  for (const u of sourceUsers) {
    if (!u.EMAIL) continue;
    const targetId = targetByEmail.get(u.EMAIL.trim().toLowerCase());
    map.set(u.ID, targetId ?? FALLBACK_USER_ID);
  }

  return map;
}

async function fetchAllUsers(side: "source" | "target") {
  const all: Array<{ ID: string; EMAIL: string }> = [];
  let start = 0;
  while (true) {
    const r = await bitrixRequest(side, "user.get", { start });
    const batch = r.result ?? [];
    all.push(...batch);
    if (r.next === undefined) break;
    start = r.next;
  }
  return all;
}

Если соответствие не найдено — назначаем на FALLBACK_USER_ID, обычно это администратор аккаунта. Без fallback'а лиды с неизвестным ASSIGNED_BY_ID упадут с ошибкой при создании, и миграция остановится посреди прогона.

File‑поля

Файлы (документы, фото, аватары) в Битриксе хранятся через FILES API. Перенести по ID нельзя — у файла в target будет другой ID. Перенос идёт через base64:

// migrate-toolkit/src/mapping/file.ts
import { bitrixRequest } from "../client";

export async function transferFile(
  sourceFileUrl: string,
  filename: string
): Promise<{ fileData: [string, string] }> {
  // 1. Скачиваем файл с source-инстанса
  const fileResponse = await fetch(sourceFileUrl);
  const buffer = Buffer.from(await fileResponse.arrayBuffer());
  const base64 = buffer.toString("base64");

  // 2. Возвращаем структуру, которую Битрикс понимает в crm.*.add
  return {
    fileData: [filename, base64],
  };
}

Эта структура передаётся в fields целевого crm.lead.add напрямую — Битрикс расшифрует base64 и положит файл в свой FILES API. Подводный камень: base64 раздувает размер тела запроса в 4/3 раза. Файл на 5 МБ превратится в payload на ~6.7 МБ. У Битрикса есть лимит на размер тела запроса (обычно 30–50 МБ), большие файлы (видео, тяжёлые PDF) лучше переносить отдельным шагом или вовсе вручную.

Кастомные поля UF_*

Если справочники UF_* совпадают по ID между инстансами — переносятся как есть. Если не совпадают — нужно ещё одно расширение getStatusMap под crm.userfield.list. На моём проекте справочники UF_* совпадали (target создавался копированием конфига source), поэтому я этот случай не реализовывал — но если у вас два независимо выросших аккаунта, готовьтесь к ещё одному маппингу.


Подводные камни

Кратко то, что прилетает посреди миграции и стоит знать заранее.

  1. Rate limit ~2 RPS. На 50 000 лидов это минимум ~8-10 минут только чистого чтения, плюс столько же на запись, плюс задержки сети. Реалистично закладывать 30–40 минут на 50 000 записей для одной сущности. Полная миграция (лиды + сделки + контакты + компании + связи) на средней базе — 2–4 часа.

  2. Разные ID кастомных полей в source и target. Поле UF_CRM_1234567890 в одном инстансе ≠ UF_CRM_0987654321 в другом, даже если они называются одинаково. Перед миграцией снимаю снапшот через crm.userfield.list для обоих инстансов и держу маппинг.

  3. FILES API через base64 раздувает payload. См. выше. Плюс — каждый файл это отдельный round‑trip к source за скачиванием, что ещё умножает время миграции.

  4. Битрикс не возвращает next после последней страницы. Я в цикле проверяю и response.next === undefined, и leads.length < PAGE_SIZE. Если проверять только что‑то одно — на одних версиях Битрикса будет бесконечный цикл, на других — обрубите последнюю неполную страницу.

  5. Связи между сущностями переносятся в правильном порядке. Сначала компании, потом контакты (которые ссылаются на компании), потом сделки/лиды (которые ссылаются на контакты). Если перенести лид раньше контакта — CONTACT_ID в target будет указывать в пустоту. Я кодирую порядок жёстко в migrate.ts:

async function main() {
  await buildMaps(); // юзеры, статусы, источники, стадии
  await migrateCompanies();
  await migrateContacts();
  await migrateLeads();
  await migrateDeals();
}

6.crm.lead.list отдаёт только активные лиды по умолчанию. Архивные/конвертированные нужно явно запрашивать через filter: { CONVERTED: "Y" } отдельным проходом. Иначе в target не уедет половина истории.


Чек‑лист dry‑run перед прогоном на проде

Перед тем как пускать миграцию на живой target‑инстанс, прохожу пять пунктов:

  1. Маппинг проверен на 10 случайных записях. Беру 10 лидов из source, прогоняю через mapping в режиме --dry-run, печатаю payload, который ушёл бы в target. Глазами проверяю: статус сматчился, ответственный сматчился, кастомные поля на месте, телефон в правильном формате.

  2. Лимиты Битрикса проверены. Если у клиента free‑tier Bitrix24, у него лимит на количество лидов в аккаунте (зависит от тарифа). На таком тарифе миграция 100 000 лидов просто не доедет — Битрикс перестанет принимать crm.lead.add после превышения. Проверяется до начала миграции.

  3. Сделан backup target‑инстанса. Если target — пустой новый аккаунт, можно пропустить. Если в target уже есть какие‑то данные (например, миграция инкрементальная или вы доливаете в существующий) — backup обязателен. Битрикс позволяет выгрузить аккаунт через раздел «Битрикс24.Маркет» → «Резервное копирование».

  4. REGISTER_SONET_EVENT: "N" добавлен во все *.add‑вызовы. Без этого после миграции лента Битрикса станет нечитаемой на пару дней.

  5. migration.log пишется в файл. Не просто в stdout — в файл, который не удалится после закрытия терминала. Минимально для каждой записи логирую: source_id, target_id, status (success / skipped / error), timestamp, error_message если есть. Этот лог — единственный способ ответить на вопрос «а что с нашим лидом #12345?» через неделю после миграции.

// migrate-toolkit/src/log.ts
import { appendFileSync } from "fs";

const LOG_PATH = `migration-${new Date().toISOString().slice(0, 10)}.log`;

export function logRecord(entry: {
  entity: string;
  sourceId: string;
  targetId?: string;
  status: "success" | "skipped" | "error";
  message?: string;
}) {
  const line = JSON.stringify({ ...entry, ts: new Date().toISOString() });
  appendFileSync(LOG_PATH, line + "\n");
}

Плоский JSON — потом легко грепать и парсить:

# Сколько лидов перенеслось
grep '"entity":"lead"' migration-2026-05-04.log | grep '"status":"success"' | wc -l

# Что упало
grep '"status":"error"' migration-2026-05-04.log | jq .

Что забрать из статьи

Три вещи, которые отделяют рабочий migration toolkit от одноразового скрипта:

  1. Идемпотентность через ORIGINATOR_ID + ORIGIN_ID. Это нативный механизм Битрикса, не велосипед. Повторный прогон не плодит дубли — Битрикс сам сверяет пару полей и возвращает существующий ID.

  2. Маппинг справочников и пользователей до начала, не во время. Статусы — по STATUS_ID с фолбэком на NAME. Пользователи — по email с фолбэком на админа. Без этих двух маппингов миграция упадёт на пятой записи.

  3. migration.log как источник правды. Плоский JSON‑лог по каждой записи. Это единственное, что позволит через неделю ответить на вопрос «дошло ли в target?» без переоткрытия аккаунтов руками.

Migration toolkit с этими тремя свойствами я гонял на одном из проектов в этом году. Около 80 000 записей суммарно по четырём сущностям, время прогона около 4.5 часов, повторных прогонов было два (после правок в маппинге кастомных полей), дублей в target — ноль.


Яков Радченко. Делаю веб‑продукты на Next.js. Следующая статья — про inbound webhook от Битрикса в Next.js: как принимать события из CRM и не упасть от тысячи одновременных запросов из лента‑обновления.