Первый файл, который я получил при переезде своего магазина с 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 в самом начале импорта стоит три строки и экономит вечер.
Когда это всё не нужно
Если миграция у вас одна и второй не предвидится — каркас не стройте. Напишите скрипт, перевезите, удалите скрипт. Разделение на контракт и адаптеры окупается начиная со второго источника, а до него это лишняя абстракция, за которую вы платите временем на ровном месте.
Итог
Каркас миграции — это одна фиксированная середина и тонкие адаптеры по краям. В середине: контракт данных, валидация, идемпотентная запись по внешним ключам, снапшоты для связей, которые могут не найтись. В адаптере: кодировка, формат, маппинг колонок, единицы, типы.

