MAATRIX / Блог / Выгрузили CRM и потеряли связи между записями

Выгрузили CRM и потеряли связи между записями

MAATRIX

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

Где именно рвётся связь

В любой системе со сложными сущностями — контакт, компания, сделка, задача, счёт — связи между записями хранятся не через название или другое «человеческое» поле, а через внутренний технический идентификатор: числовой автоинкремент (id = 4821) или UUID (8f3a1c2e-...). Запись сделки не содержит текста «ООО Ромашка» в поле «компания» — она содержит company_id = 4821, и только при отображении в интерфейсе CRM подставляет вместо числа человекочитаемое имя, обращаясь к таблице компаний.

Это стандартная схема реляционной модели: одна таблица хранит компании, другая — контакты со ссылкой company_id, третья — сделки со ссылками contact_id и company_id, четвёртая — задачи со ссылкой deal_id. Именно по этим числовым ссылкам, а не по совпадению имён, система строит карточку «вся история клиента» на экране.

Проблема начинается там, где экспорт ориентирован не на структуру данных, а на то, что видно на экране. Кнопка «Экспорт в Excel» в интерфейсе CRM обычно тянет то, что показывает пользователю: имя компании текстом, ФИО контакта текстом, название сделки текстом. Внутренний id, по которому эти записи на самом деле связаны друг с другом, в такой выгрузке просто не участвует — он не нужен для чтения человеком, поэтому его никто туда не кладёт.

Контакт, компания, сделка: типичный сценарий

Возьмём конкретный пример. В старой CRM есть:

  • компания id=101, название «ООО Ромашка»;
  • контакт id=550, имя «Иванов И. И.», поле company_id=101;
  • сделка id=980, поле contact_id=550, поле company_id=101;
  • задача id=2200, поле deal_id=980, текст «согласовать договор».

Экспорт через UI даёт четыре Excel-листа: «Компании», «Контакты», «Сделки», «Задачи». В листе «Контакты» вместо company_id=101 — колонка «Компания» со значением текста «ООО Ромашка». В листе «Сделки» — колонка «Контакт» со значением «Иванов И. И.» и колонка «Компания» с тем же текстом. Числовые id нигде не фигурируют, потому что человеку они не нужны — ему нужно имя.

При импорте в новую систему компания «ООО Ромашка» получает новый id, скажем, id=7. Контакт получает id=302. Чтобы правильно проставить у контакта company_id=7, импортёру нужно понять, что текст «ООО Ромашка» в исходной выгрузке соответствовал именно этой компании — а не однофамильной записи, случайно попавшей в базу дважды из-за опечатки менеджера. Если компаний с похожим или идентичным названием несколько (а в любой боевой CRM за пару лет работы такое почти гарантированно накапливается), сопоставление по тексту либо ломается явно, либо — что хуже — тихо привязывает контакт не к той компании.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Экспорт «плоских» полей — самая частая ошибка

Разница между «плоским» и «связным» экспортом — это разница между тем, что видно в интерфейсе, и тем, что реально хранится в базе.

Способ экспортаЧто выгружаетсяЧто происходит со связями
Кнопка «Экспорт» в UI CRM (CSV/Excel)Читаемые поля: имена, названия, статусы текстомВнутренние ID теряются, связь нужно восстанавливать по совпадению текста
REST API системыОбычно полный объект записи, включая id и ссылочные поля (company_id, contact_id)Связи сохраняются, если явно выгружать поля-ссылки, а не только name/title
Прямой дамп базы (SQL)Все таблицы как есть, включая внешние ключиСвязи сохраняются полностью — но доступен не для всех SaaS-CRM

С прямым дампом базы всё просто: если у вас есть доступ к собственной СУБД (например, self-hosted CRM на своём сервере), pg_dump или mysqldump выгрузит все таблицы вместе с внешними ключами, и связи в принципе не могут потеряться — это тот же случай, что разбирался в статье про перенос базы данных между серверами: если переносить саму СУБД, а не производный экспорт, структура остаётся целой.

Проблема возникает именно там, где прямого доступа к базе нет — это почти всегда так для облачных SaaS-CRM (Bitrix24, amoCRM, HubSpot, Salesforce и им подобные), где данные физически лежат на серверах вендора. Тогда единственный путь наружу — либо экспорт через UI (быстро, но без ID), либо экспорт через REST API (медленнее, но с полным доступом к полям записи, включая ссылочные).

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

Отдельная ловушка — сам формат CSV, даже если вы вытащили в него и текстовые поля, и числовые ID: экранирование, типы данных и кодировка CSV умеют молча портить данные независимо от того, что в них лежит. Это разобрано подробно в статье CSV как формат обмена между базами — стоит прочитать её перед тем, как выбирать CSV в качестве промежуточного формата для выгрузки со связями.

Что обязательно должно быть в выгрузке

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

  1. Собственный внутренний ID записи (числовой или UUID) — без него не с чем сопоставлять записи при повторном импорте или сверке.
  2. Все ссылочные поля на другие сущности как ID, а не как текст: company_id, contact_id, deal_id, owner_id, parent_id — в зависимости от структуры конкретной системы.
  3. Тип связи, если он у CRM не единственный: например, «основной контакт» и «дополнительный контакт» сделки — это две разные ссылки, и их легко перепутать при ручном сопоставлении по тексту.
  4. Естественный ключ как резерв, а не основной способ связывания: email контакта, ИНН компании, номер телефона. Если внутренний ID экспортировать не получается вообще (система API не даёт, а в UI-экспорте его нет), естественный ключ — единственная страховка, но он ненадёжен там, где значения не уникальны или заполнены не всегда.

Перед тем как вообще начинать выгрузку, стоит явно выписать схему сущностей и связей в исходной системе — какие сущности есть, какие поля в них ссылаются на какие другие сущности. Для популярных CRM это обычно есть в документации API (список полей объекта с пометкой, какие из них — ссылки), для самописных систем — в схеме базы данных. Без этой инвентаризации легко пропустить неочевидную связь: например, задача может ссылаться не только на сделку, но и напрямую на контакт, и оба поля нужно выгружать отдельно.

Таблица соответствия ID: практика переноса

Даже если исходные ID выгружены, они бесполезны сами по себе — новая система присвоит записям собственные, новые ID. Задача импорта — построить и сохранить таблицу соответствия «старый ID → новый ID» для каждого типа сущности, а затем использовать её при простановке ссылочных полей.

Порядок действий:

  1. Импортировать сущности без исходящих связей первыми: компании (если они ни на что не ссылаются) обычно идут первыми. Компания, у которой есть родительская компания (parent_company_id), — уже сложнее, и её лучше импортировать в два прохода: сначала без родителя, потом проставить ссылку.
  2. При вставке каждой записи сохранять пару (старый_id, новый_id) в отдельную служебную таблицу — назовём её id_map со столбцами entity_type, old_id, new_id.
  3. Импортировать зависимые сущности (контакты, затем сделки, затем задачи) в порядке возрастания зависимостей, на каждом шаге беря new_id для ссылочного поля из id_map по значению old_id из исходной выгрузки.

Пример на псевдокоде для контактов, зависящих от уже импортированных компаний:

# company_map: {old_company_id: new_company_id}, заполнена на предыдущем шаге
for row in old_contacts_export:
    new_company_id = company_map.get(row["company_id"])
    if new_company_id is None:
        log.warning(f"Контакт {row['id']}: компания {row['company_id']} не найдена в маппинге")
    new_id = insert_contact(
        name=row["name"],
        email=row["email"],
        company_id=new_company_id,
    )
    contact_map[row["id"]] = new_id

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

Если исходная система вообще не отдаёт внутренние ID (редко, но бывает у совсем закрытых экспортов), таблицу id_map строят по естественному ключу — но тогда сопоставление нужно делать особенно аккуратно, с ручной проверкой конфликтов: два контакта с одинаковым email, компании с совпадающим названием и так далее.

Проверка после переноса: тестовые записи и сверка

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

Ручная проверка на выборке. Возьмите 10–20 записей из «сложных» сущностей — сделок или задач, у которых несколько исходящих связей — и вручную пройдите цепочку в старой и новой системе: сделка → её контакт → его компания → задачи по этой сделке. Совпадает ли набор в обеих системах. Стоит специально выбрать записи с потенциально проблемными случаями: контакт без компании, сделка с несколькими участниками, задача, привязанная напрямую к контакту, а не к сделке.

Автоматическая проверка целостности. Если новое место хранения — реляционная СУБД (например, PostgreSQL на своём сервере), внешние ключи можно проверить прямым запросом на «осиротевшие» ссылки — ссылки, указывающие на несуществующую запись:

-- сколько сделок ссылаются на контакт, которого нет в таблице contacts
SELECT count(*) FROM deals d
LEFT JOIN contacts c ON d.contact_id = c.id
WHERE d.contact_id IS NOT NULL AND c.id IS NULL;

-- то же самое для компаний у контактов
SELECT count(*) FROM contacts co
LEFT JOIN companies cm ON co.company_id = cm.id
WHERE co.company_id IS NOT NULL AND cm.id IS NULL;

Если оба запроса возвращают 0 — связи целы на уровне ссылочной целостности. Это не доказывает, что связи логически правильные (контакт мог привязаться не к той компании при сопоставлении по тексту), но отсекает грубые потери, когда ссылка вообще ведёт в пустоту.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли восстановить потерянные связи задним числом, если экспорт уже сделан без ID?

Частично — если у записей есть уникальные естественные ключи (email, ИНН, телефон) и в старой системе ещё есть доступ для сверки. Если старая система уже недоступна, а естественных ключей недостаточно — часть связей восстановится только вручную, по здравому смыслу, и часть будет потеряна безвозвратно.

Что если в старой CRM у двух компаний одинаковое название?

Это ровно тот случай, где сопоставление по тексту вместо ID даёт неверный результат: один из двух контактов молча привяжется не к той компании. Решается только выгрузкой внутреннего ID до того, как эта проблема успела произойти при импорте.

Нужно ли выгружать удалённые/архивные записи, если на них есть ссылки?

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

REST API систем всегда отдаёт внутренние ID?

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

Стоит ли строить id_map в отдельной таблице или достаточно словаря в памяти скрипта переноса?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →