Первый файл, который я получил при переезде своего магазина с InSales, парсер прочитал как одну длинную строку мусора. Выгрузка заказов оказалась в UTF-16 LE с BOM и табуляцией вместо запятой — ни один разумный дефолт не подошёл.

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

Отсюда вывод, к которому стоит прийти раньше, чем на третьем проекте: середина должна быть одна, а платформенная специфика — жить в тонком слое на входе. Ниже — как этот каркас устроен у меня, с кодом из работающего проекта: собственного магазина, который я перевёз с InSales на свой движок на Next.js и PostgreSQL.

Сразу оговорюсь про доставленные миграции: полностью пройдена одна — та, о которой пойдёт речь. Про требования к адаптерам под 1С‑выгрузки и товароучётные API дальше я говорю как о проектных требованиях к каркасу; готовыми кейсами они не являются. Это важно, чтобы вы правильно оценили вес каждого утверждения.

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

Что стоит посередине

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

  • товары и варианты,

  • категории,

  • клиенты,

  • заказы и позиции заказа,

  • адреса доставки,

  • адреса страниц старого сайта.

Всё, что дальше делает система — валидация, нормализация, связывание, запись — работает только с этими типами и ничего не знает про InSales, 1С или CSV. Адаптер знает про источник и не знает про базу. Такое разделение проверяется одним вопросом: сколько файлов придётся тронуть, чтобы добавить новый источник. Если больше одного — разделение вы ещё не сделали.

Практический критерий готовности каркаса — цена новой интеграции. Она должна быть равна «написать маппинг», а не «написать импортёр».

Адаптер InSales: что реально приезжает в выгрузке

Теория заканчивается на первом же файле. Выгрузка заказов из InSales — это:

UTF-16 LE с BOM, разделитель — табуляция. Не UTF-8, как ожидает почти любой парсер по умолчанию. Наивное чтение даёт либо мусор, либо одну длинную строку.

const buffer = readFileSync(path.resolve(csvPath));
// UTF-16 LE; strip BOM.
const text = buffer.toString("utf16le").replace(/^/, "");

Один заказ — несколько строк. Каждая позиция заказа лежит отдельной строкой, доставка — ещё одной строкой с тем же номером заказа, между заказами попадаются пустые строки. Сумма указана построчно. То есть в файле нет объекта «заказ» — его нужно собрать.

const orders = new Map<string, ParsedOrder>();
for (const row of rows.slice(1)) {
  const n = get(row, "№");
  if (!n) continue;                       // пустые строки-разделители
  let order = orders.get(n);
  if (!order) { order = newOrder(row); orders.set(n, order); }

  const title = get(row, "Наименование товара (услуги)");
  const sum = num(get(row, "Сумма для получения"));
  if (title === "Доставка") order.shipping += sum;   // строка доставки
  else if (title) order.items.push({ title, sum, /* … */ });
}

Кавычки с табуляциями и переносами внутри. Готовый csv‑парсер на табуляции ставить не хотелось ради одной зависимости, поэтому парсер минимальный, но с поддержкой экранирования — иначе адрес доставки с переносом строки разваливает всю таблицу.

Экспорт клиентов содержит дубли. В моём случае ~6 400 строк на ~5 100 уникальных адресов: гость оформлял заказ несколько раз до регистрации, и платформа честно отдаёт каждую запись. Дедупликация по нижнему регистру адреса — работа адаптера; база про эти дубли ничего не знает.

Всего в этой миграции переехали каталог, 3 123 заказа за десять лет и 7 636 адресов страниц. Ни одно из чисел не влияет на архитектуру, но они показывают, что грабли ловятся не на трёх тестовых строках.

Идемпотентность: не «желательно», а условие работы

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

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

// Номера заказов из старой системы живут в своём пространстве имён:
// импортированные — IS-<№>, новые — IW-YYMM-…  Пересечься не могут.
const orderNumber = `IS-${order.num}`;

const existing = new Set(
  (await prisma.order.findMany({
    where: { orderNumber: { startsWith: "IS-" } },
    select: { orderNumber: true },
  })).map((o) => o.orderNumber),
);

if (existing.has(orderNumber)) { skipped += 1; continue; }

Два решения тут важнее самого кода.

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

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

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

И флаг --dry-run, который разбирает файл и печатает статистику, ничего не записывая. Первый прогон всегда такой — он ловит несовпадение колонок до того, как в базу уедет мусор.

Что меняется в адаптере под другие источники

Каркас имеет смысл только если он выдерживает второй источник. Что придётся заложить — по опыту чтения выгрузок и API; доставленных переездов на этих источниках у меня пока нет:

Единицы. Товароучётные системы часто отдают деньги в минимальных единицах — копейках. Конвертация обязана быть в адаптере, до бизнес‑логики она доходить не должна: контракт должен принимать один тип денег, иначе рано или поздно копейки уедут в рубли на одном из путей.

Идентификаторы. Внутренний ID платформы после переезда не значит ничего. Связывать нужно по внешнему коду — артикулу, коду номенклатуры — и заранее решать, что делать с дублями и пустыми значениями. Это самая частая причина «импорт прошёл, но каталог поехал».

Типы. CSV отдаёт всё строками, API — типизированный JSON, но с собственными представлениями дат и чисел. Приведение типов — работа адаптера. Контракт видит Date и Decimal, а не «строку, похожую на дату».

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

Катаут: старый магазин живёт до последнего дня

Отдельная часть, которую нельзя переносить на «потом»: как переключаются.

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

Что должно быть готово до переключения:

  • карта редиректов со старых адресов на новые, один в один;

  • проверка выборкой вручную — я проверял 120 адресов из 7 636, каждый должен вести на живую страницу за один переход, без цепочек;

  • сохранение прежних адресов там, где это возможно — самый дешёвый способ ничего не потерять.

Грабли, которых нет в чек‑листах

Первая — та, о которой я писал отдельно и которая стоила мне двух месяцев. Редиректы были исправны, выборка проходила, а органический трафик не вернулся восемь недель. Две недели я проверял не то: перепроверял цепочки редиректов, искал потери в карте сайта, грешил на скорость ответа сервера. Все три гипотезы оказались неверными. Причина была не в переезде: 2 041 живая страница осталась без записи в карте сайта и без единой внутренней ссылки на себя. Редирект переносит вес со старого адреса, но не удерживает страницу в индексе — странице нужен путь. Разбор с цифрами у меня в отдельной статье, здесь важен вывод для каркаса: после миграции проверяется не только достижимость старых адресов, но и достижимость новых страниц изнутри сайта.

Вторая — порядок импорта. Заказы ссылаются на клиентов и товары, поэтому справочники грузятся первыми, транзакционные данные вторыми. Иначе связывание не сработает, и вы получите корректный по цифрам, но безжизненный набор заказов без единой связи.

Третья — кодировка ломается тихо. UTF-16, прочитанный как UTF-8, не падает с ошибкой. Он даёт строки, которые выглядят как данные и проходят валидацию. Проверка первых байт файла на BOM в самом начале импорта стоит три строки и экономит вечер.

Когда это всё не нужно

Если миграция у вас одна и второй не предвидится — каркас не стройте. Напишите скрипт, перевезите, удалите скрипт. Разделение на контракт и адаптеры окупается начиная со второго источника, а до него это лишняя абстракция, за которую вы платите временем на ровном месте.

Итог

Каркас миграции — это одна фиксированная середина и тонкие адаптеры по краям. В середине: контракт данных, валидация, идемпотентная запись по внешним ключам, снапшоты для связей, которые могут не найтись. В адаптере: кодировка, формат, маппинг колонок, единицы, типы.