Для AI-агентов: markdown этой страницы — /docs-content/recipes/erp-sync.md индекс документации — /llms.txt
Синхронизация контактов с 1С или ERP
Сложность: продвинутый | Скоупы: crm | Стек: cURL / JavaScript
Переносим контакты между учётной системой и Битрикс24: новые записи создаются с обеих сторон, правки существующих едут из учётной системы в Битрикс24, повторный запуск не задваивает клиентов. Обмен идёт по расписанию и разбирает только то, что изменилось с прошлого раза.
Что понадобится
- API-ключ Вайбкод со скоупом
crm - Доступ к API вашей учётной системы
- Node.js 18 или новее
Во всех примерах $VIBE_URL — базовый адрес https://vibecode.bitrix24.tech, $VIBE_API_KEY — ваш API-ключ.
Сторона учётной системы в этом рецепте не описана. У 1С, SAP и самописной базы разные протоколы, поэтому всё, что происходит на их стороне, вынесено в пять функций-заглушек: fetchErpContacts и createErpContact — чтение и запись записей, findErpContactByKey — поиск существующей записи по ключу, loadSyncCursor и saveSyncCursor — хранение отметки последнего запуска. Эти пять функций вы заменяете своим кодом. Всё остальное — вызовы Вайбкод — работает как показано.
Как устроено решение
- Выбираем ключ сопоставления. Записи связываются по значению, которое есть в обеих системах и не меняется — ИНН, внешний код клиента или адрес электронной почты.
- Читаем из Битрикс24 контакты, изменённые после прошлого запуска, а не всю базу.
- Сопоставляем их с выгрузкой из учётной системы и раскладываем на три группы: совпавшие, только в Битрикс24, только в учётной системе.
- Недостающие контакты создаём в Битрикс24 одним пакетным вызовом, расхождения в совпавших — обновляем.
- Сохраняем отметку времени запуска, от неё отсчитывается следующий цикл.
Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».
Шаг 1. Ключ сопоставления и поле под внешний код
Связывать записи по имени нельзя — «Иван Петров» встречается многократно. Нужен внешний код клиента, ИНН или адрес электронной почты.
Внешний код хранится в пользовательском поле контакта. Их имена на каждом портале свои и выглядят как ufCrm_1729594209 — список возвращает GET /v1/contacts/fields.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/contacts/fields"
JavaScript
const res = await fetch(`${VIBE_URL}/v1/contacts/fields`, {
headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data } = await res.json()
const custom = Object.keys(data.fields).filter(name => name.startsWith('ufCrm_'))
{
"success": true,
"data": {
"fields": {
"id": { "type": "number", "readonly": true, "label": "ID" },
"email": { "type": "multifield", "readonly": false, "label": "Email" },
"ufCrm_1729594209": { "type": "string", "readonly": false, "label": "Код клиента в 1С" }
}
}
}
Пользовательские поля отличаются от объявленных префиксом ufCrm_ в имени. Подпись поля лежит в label — по ней среди пользовательских полей находится нужное, то, где хранится внешний код.
Имя поля выясняется один раз и записывается в конфигурацию скрипта. Если внешнего кода нет, ключом становится адрес электронной почты — по нему фильтр работает без дополнительной настройки.
Шаг 2. Что изменилось с прошлого запуска
Полная выгрузка базы на каждый цикл не нужна. POST /v1/contacts/search с фильтром по updatedTime отдаёт только записи, изменённые после отметки прошлого запуска. Синтаксис операторов — фильтрация.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/contacts/search" \
-d '{
"filter": { ">updatedTime": "2026-07-20T00:00:00Z" },
"select": ["id", "name", "lastName", "email", "phone", "updatedTime"],
"sort": { "updatedTime": "asc" },
"limit": 50,
"offset": 0
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/contacts/search`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
filter: { '>updatedTime': since },
select: ['id', 'name', 'lastName', 'email', 'phone', 'updatedTime'],
sort: { updatedTime: 'asc' },
limit: 50,
offset,
}),
})
const { data, meta } = await res.json()
{
"success": true,
"data": [
{
"id": 2521,
"name": "Пробный",
"lastName": "Рецепт",
"email": "probe@example.com",
"phone": "+70000000001",
"updatedTime": "2026-07-21T08:59:22.000Z"
}
],
"meta": { "total": 1, "hasMore": false, "durationMs": 574 }
}
Отметку времени контакта несёт поле updatedTime, не updatedAt — у сделок это поле называется иначе. Признак «есть ли ещё страницы» — meta.hasMore.
Страницы перебираются параметром offset, и его шаг кратен 50: offset: 25 вернёт ту же первую страницу, что и offset: 0. Берите limit кратным 50 и увеличивайте offset на ту же величину.
Адреса и телефоны в ответе приходят строками — "email": "probe@example.com". Полный набор контактных значений с типами лежит в поле fm.
Шаг 3. Сопоставление и три группы
Сопоставление — обычный код на стороне скрипта: строим указатель по ключу и проходим выгрузку учётной системы. Вызовов к API на этом шаге нет, поэтому и примера на cURL здесь нет — показывать нечего.
// bitrixKey и erpKey обязаны доставать ОДИН И ТОТ ЖЕ признак с двух сторон:
// либо внешний код у обеих, либо почту у обеих. Указатель по почте против
// поиска по внешнему коду не совпадёт никогда — обмен начнёт плодить дубли.
function match(bitrixContacts, erpContacts, bitrixKey, erpKey) {
const index = new Map(bitrixContacts.map(c => [bitrixKey(c), c]).filter(([k]) => k))
const paired = []
const onlyInErp = []
const pairedIds = new Set()
for (const erp of erpContacts) {
const found = index.get(erpKey(erp))
if (found) {
paired.push({ bitrix: found, erp })
pairedIds.add(found.id)
} else {
onlyInErp.push(erp)
}
}
const onlyInBitrix = bitrixContacts.filter(c => !pairedIds.has(c.id))
return { paired, onlyInBitrix, onlyInErp }
}
Ключ нормализуйте перед сравнением — адрес приводите к нижнему регистру, из ИНН и телефона убирайте пробелы и дефисы. Иначе Ivanov@Example.com и ivanov@example.com разъедутся в две записи.
Шаг 4. Создание недостающих контактов пачкой
Создавать по одному не нужно. POST /v1/contacts/batch принимает массив items и возвращает результат по каждому элементу отдельно.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/contacts/batch" \
-d '{
"action": "create",
"items": [
{
"name": "Иван",
"lastName": "Петров",
"email": [{ "value": "petrov@example.com", "typeId": "WORK" }],
"phone": [{ "value": "+70000000001", "typeId": "WORK" }],
"sourceId": "OTHER",
"sourceDescription": "Импорт из учётной системы"
},
{
"name": "Мария",
"lastName": "Сидорова",
"email": [{ "value": "sidorova@example.com", "typeId": "WORK" }]
}
]
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/contacts/batch`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'create',
items: onlyInErp.map(erp => ({
name: erp.name,
lastName: erp.lastName,
email: [{ value: erp.email, typeId: 'WORK' }],
phone: [{ value: erp.phone, typeId: 'WORK' }],
sourceId: 'OTHER',
sourceDescription: 'Импорт из учётной системы',
})),
}),
})
const { data } = await res.json()
{
"success": true,
"data": {
"results": [
{ "index": 0, "success": true, "id": 2523 },
{ "index": 1, "success": true, "id": 2525 }
],
"summary": { "total": 2, "succeeded": 2, "failed": 0 }
}
}
Поле index указывает на позицию в отправленном массиве items — по нему сопоставляется, какой записи учётной системы какой id в Битрикс24 достался. Эти пары надо сохранить у себя, иначе на следующем цикле сопоставление придётся строить заново.
Отказ по одной записи не отменяет остальные. Неуспешный элемент приходит в том же массиве results в форме { "index": 3, "success": false, "error": "<код>", "message": "<пояснение>" } — разбирайте массив целиком, а не только summary.
Адрес и телефон передаются как [{ "value": …, "typeId": "WORK" }]. Форма с ключами VALUE и VALUE_TYPE отклоняется с INVALID_MULTIFIELD_SHAPE. Одиночную строку эндпоинт тоже принимает.
Тем же эндпоинтом идут обновления и удаления — меняется только action:
{ "action": "update", "items": [{ "id": 2523, "fields": { "post": "Директор" } }] }
{ "action": "delete", "ids": [2523, 2525] }
Шаг 5. Обновление совпавших записей
Расхождения в совпавших парах правятся точечно — PATCH /v1/contacts/:id принимает только изменившиеся поля.
cURL
curl -s -X PATCH -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/contacts/2521" \
-d '{ "post": "Директор" }'
JavaScript
function diff(bitrix, erp) {
const changes = {}
if (erp.name && bitrix.name !== erp.name) changes.name = erp.name
if (erp.lastName && bitrix.lastName !== erp.lastName) changes.lastName = erp.lastName
if (erp.post && bitrix.post !== erp.post) changes.post = erp.post
return changes
}
const changes = diff(pair.bitrix, pair.erp)
if (Object.keys(changes).length > 0) {
await fetch(`${VIBE_URL}/v1/contacts/${pair.bitrix.id}`, {
method: 'PATCH',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
})
}
{
"success": true,
"data": {
"id": 2521,
"name": "Пробный",
"lastName": "Рецепт",
"post": "Директор",
"updatedTime": "2026-07-21T09:03:14.000Z"
}
}
Ответ возвращает контакт целиком с новым updatedTime — по нему видно, что правка действительно применилась.
Отправляйте запрос только когда changes непустой. Обновление без изменений всё равно двигает updatedTime, и на следующем цикле контакт снова попадёт в выборку изменённых — цикл начнёт перебирать сам себя.
Ограничения
Очередь портала. Цикл доверок и пакетных записей упирается в очередь портала раньше других отказов. Портал выполняет ограниченное число запросов к API одновременно, остальные ждут в очереди. Предел задаётся настройками платформы и на конкретное число полагаться нельзя. Цикл, который шлёт запросы подряд, рано или поздно получит 429. Код QUEUE_OVERFLOW означает, что очередь переполнена и запрос отклонён сразу, QUEUE_TIMEOUT — что запрос прождал места дольше 30 секунд и до Битрикс24 не дошёл, поэтому повторить его безопасно.
{
"success": false,
"error": {
"code": "QUEUE_OVERFLOW",
"message": "Portal queue overloaded — 128 Bitrix24 calls already pending",
"userMessage": "Слишком много одновременных запросов к Bitrix24 — повторите через несколько секунд.",
"hint": "Honor the Retry-After header. Use exponential backoff with jitter for repeated failures.",
"retryAfter": 3
}
}
Рекомендованная пауза приходит в заголовке Retry-After и дублируется в error.retryAfter. Повторяйте с нарастающей задержкой, а не мгновенно — готовая функция повтора есть в Лимитах и оптимизации.
Постраничный обход и окно. Постраничный обход через offset несовместим с оконным разбором широкого диапазона дат: фильтр >updatedTime со старой отметкой охватывает больше 14 дней, окно включается автоматически, и вторая страница возвращает 400 UNSTABLE_OFFSET_PAGINATION. Поэтому запрос идёт с autoWindow: false и сортировкой по id.
Предел пакета. Пакетный вызов ограничен по числу элементов в одном запросе. Точное значение задаёт администратор платформы, превышение отклоняет весь пакет с кодом BATCH_LIMIT_EXCEEDED. Разбивайте выгрузку на порции и отправляйте их последовательно.
Дубли при инкрементальном чтении. Инкрементальное чтение отвечает на вопрос «что изменилось», но не на вопрос «чего не хватает». Указатель сопоставления строится из среза изменений, поэтому запись, чей двойник с прошлого раза не менялся, в нём отсутствует — и выглядит как новая. Создавать её нельзя: скрипт сперва доискивает такую запись в Битрикс24 по ключу отдельным запросом и только при пустом ответе создаёт. Без этой доверки обмен плодит дубли на каждом прогоне, и симметричное окно на обеих сторонах от этого не спасает.
Правки — в одну сторону. Правки существующих записей едут только в одну сторону — из учётной системы в Битрикс24. Новые записи при этом создаются с обеих сторон. Обратная передача правок из Битрикс24 в учётную систему в рецепт не входит: она требует правила разрешения конфликтов по каждому полю, а без него одна и та же запись переписывалась бы на каждом запуске туда и обратно.
Кто выигрывает конфликт. Направление правки при расхождении решает автор скрипта. В примере при конфликте выигрывает учётная система: её значения перезаписывают поля Битрикс24. Если менеджер поправил телефон в карточке, а в учётной системе остался старый — правка менеджера пропадёт. Другой вариант — сравнивать отметки времени с обеих сторон и брать более свежую.
Отметка последнего запуска. Отметка последнего запуска должна переживать перезапуск процесса. Хранить её в переменной внутри скрипта нельзя: после перезагрузки сервера цикл начнётся с нуля и перечитает всю базу.
Таблица сопоставления вне Вайбкод. Соответствие «запись учётной системы — контакт Битрикс24» хранится вне Вайбкод. Если его потерять, повторный запуск сопоставит записи заново по ключу, а для тех, у кого ключ пустой, создаст дубли.
Журнала обмена нет. Журнал обмена скрипт не ведёт. Ответ пакетного вызова даёт index, success и id по каждой записи — сохраняйте эти строки у себя, иначе после сбоя нельзя ответить, какие записи прошли, а какие нет.
Удаление не переносится. Удаление не синхронизируется. Контакт, удалённый в учётной системе, останется в Битрикс24 — обмен переносит создание и изменение.
Сон BLACKHOLE-сервера. Если обмен запускается на BLACKHOLE-сервере, отключите автоматический сон. Таймер простоя считает входящие обращения к серверу, а обмен делает только исходящие вызовы — с точки зрения таймера сервер простаивает и засыпает вместе с расписанием. Отключается через PATCH /v1/infra/servers/:id/sleep со значением sleepAfterMinutes: null. На вытесняемых тарифах (bc-agent, bc-micro) этого недостаточно: облако всё равно перезапускает машину примерно раз в сутки. Отметка последнего запуска перезапуск переживёт, но за время сна расхождение накапливается, и первый цикл после пробуждения пойдёт большой пачкой — рассчитывайте размер порции на этот случай, а не на обычный час.
Полный перечень кодов — Ошибки.
Полный код
// erp-sync.js — обмен контактами между учётной системой и Битрикс24
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Переменная окружения VIBE_API_KEY не задана')
const PAGE = 50
const BATCH = 50
// Имя пользовательского поля с внешним кодом, выясненное на шаге 1.
// Пусто — ключом сопоставления служит адрес электронной почты.
const EXTERNAL_KEY_FIELD = process.env.ERP_KEY_FIELD ?? null
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
// Любой отказ 429 (QUEUE_OVERFLOW, QUEUE_TIMEOUT, RATE_LIMITED) означает,
// что запрос до Битрикс24 не дошёл, — повторить его безопасно, контакты не задвоятся. Сколько ждать,
// говорит заголовок Retry-After, а при его отсутствии поле error.retryAfter.
// К паузе добавляется случайная доля
// секунды, чтобы параллельные копии синхронизации не пошли на повтор разом.
// Без этого повтора длинная выгрузка обрывается на первой же занятой очереди.
const MAX_RETRIES = 5
async function apiCall(url, init = {}, onError) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, init)
const body = await res.json().catch(() => null)
if (res.status === 429 && attempt < MAX_RETRIES) {
// Рекомендованную паузу берём как есть: умножать её на номер попытки
// нельзя — из Retry-After: 3 на пятой попытке вышло бы 48 секунд.
// Экспонента нужна только когда сервер паузу не назвал.
const advised = res.headers.get('Retry-After') ?? body?.error?.retryAfter ?? null
// Потолок в 60 с: рекомендованную паузу мы уважаем, но неожиданно
// большое значение от промежуточного прокси не должно вешать цикл.
const base = advised === null ? Math.min(2 ** attempt, 30) : Number(advised)
const wait = Math.min(base, 60) + Math.random()
console.warn(`Очередь портала занята, повтор через ${Math.round(wait)} с`)
await new Promise(resolve => setTimeout(resolve, wait * 1000))
continue
}
if (!body?.success) {
if (onError) return onError(body, res)
throw new Error(body?.error?.message ?? `запрос отклонён (${res.status})`)
}
return body
}
}
// --- сторона учётной системы: замените своим кодом -------------------------
async function fetchErpContacts(since) {
// Верните массив вида
// { externalKey, name, lastName, email, phone, post },
// изменённый после отметки since — ТЕМ ЖЕ окном, что и сторона Битрикс24.
// Полная выгрузка здесь против инкрементальной там означает, что записи,
// чей контакт с прошлого раза не менялся, не найдутся в указателе и будут
// созданы заново на каждом прогоне.
return []
}
async function createErpContact(contact) { return { externalKey: String(contact.id) } }
// Зеркало доверки для стороны учётной системы: по ключу вернуть существующую
// запись или null. Без неё контакт, изменившийся в Битрикс24, выгружается
// повторно каждый раз, когда его двойник в учётной системе не менялся.
async function findErpContactByKey(key) { return null }
async function loadSyncCursor() { return process.env.SYNC_SINCE ?? '1970-01-01T00:00:00Z' }
async function saveSyncCursor(iso) { console.log('новая отметка:', iso) }
// ---------------------------------------------------------------------------
const normalize = value => String(value ?? '').trim().toLowerCase()
async function fetchChangedContacts(since) {
const all = []
for (let offset = 0; ; offset += PAGE) {
const body = await apiCall(`${VIBE_URL}/v1/contacts/search`, {
method: 'POST',
headers,
body: JSON.stringify({
filter: { '>updatedTime': since },
select: EXTERNAL_KEY_FIELD
? ['id', 'name', 'lastName', 'post', 'email', 'phone', 'updatedTime', EXTERNAL_KEY_FIELD]
: ['id', 'name', 'lastName', 'post', 'email', 'phone', 'updatedTime'],
// Фильтр по дате с диапазоном шире 14 дней включает оконный обход, а он
// несовместим с offset: второй страницей приходит 400
// UNSTABLE_OFFSET_PAGINATION. Отключаем окно и сортируем по id —
// это даёт обычную постраничную выдачу, устойчивую к offset.
autoWindow: false,
sort: { id: 'asc' },
limit: PAGE,
offset,
}),
})
all.push(...body.data)
if (!body.meta?.hasMore) return all
}
}
// Обе стороны ключуются одним и тем же признаком. Смешивать нельзя: указатель,
// построенный по почте, никогда не совпадёт с внешним кодом, и тогда каждый
// запуск считал бы все записи новыми и создавал дубли.
const bitrixKey = contact => normalize(EXTERNAL_KEY_FIELD ? contact[EXTERNAL_KEY_FIELD] : contact.email)
const erpKey = erp => normalize(EXTERNAL_KEY_FIELD ? erp.externalKey : erp.email)
function match(bitrixContacts, erpContacts) {
const index = new Map()
for (const contact of bitrixContacts) {
const key = bitrixKey(contact)
if (key) index.set(key, contact)
}
const paired = []
const onlyInErp = []
const pairedIds = new Set()
for (const erp of erpContacts) {
const key = erpKey(erp)
const found = key ? index.get(key) : undefined
if (found) {
paired.push({ bitrix: found, erp })
pairedIds.add(found.id)
} else {
onlyInErp.push(erp)
}
}
return {
paired,
onlyInErp,
onlyInBitrix: bitrixContacts.filter(c => !pairedIds.has(c.id)),
}
}
async function findInBitrixByKey(erp) {
// В фильтр уходит ИСХОДНОЕ значение, без приведения к нижнему регистру:
// нижний регистр нужен для сравнения в памяти, а портал сравнивает как есть.
// Внешний код «A-1001», сохранённый в верхнем регистре, по «a-1001» не найдётся,
// доверка вернёт пусто — и создаст тот самый дубль, ради которого она добавлена.
const key = EXTERNAL_KEY_FIELD ? erp.externalKey : erp.email
if (!key) return null
const body = await apiCall(`${VIBE_URL}/v1/contacts/search`, {
method: 'POST',
headers,
body: JSON.stringify({
filter: EXTERNAL_KEY_FIELD ? { [EXTERNAL_KEY_FIELD]: key } : { email: key },
select: ['id', 'name', 'lastName', 'post', 'email', 'phone'],
limit: 1,
}),
})
return body.data[0] ?? null
}
async function createInBitrix(erpContacts) {
const created = []
for (let i = 0; i < erpContacts.length; i += BATCH) {
const chunk = erpContacts.slice(i, i + BATCH)
const body = await apiCall(`${VIBE_URL}/v1/contacts/batch`, {
method: 'POST',
headers,
body: JSON.stringify({
action: 'create',
items: chunk.map(erp => ({
name: erp.name,
lastName: erp.lastName,
post: erp.post,
email: erp.email ? [{ value: erp.email, typeId: 'WORK' }] : undefined,
phone: erp.phone ? [{ value: erp.phone, typeId: 'WORK' }] : undefined,
sourceId: 'OTHER',
sourceDescription: 'Импорт из учётной системы',
// Без записи внешнего кода созданный контакт не совпадёт на следующем запуске.
...(EXTERNAL_KEY_FIELD ? { [EXTERNAL_KEY_FIELD]: erp.externalKey } : {}),
})),
}),
})
for (const row of body.data.results) {
const source = chunk[row.index]
if (row.success) created.push({ externalKey: source.externalKey, bitrixId: row.id })
else console.log(`не создан ${source.externalKey}: ${row.error} — ${row.message}`)
}
}
return created
}
function diff(bitrix, erp) {
const changes = {}
if (erp.name && bitrix.name !== erp.name) changes.name = erp.name
if (erp.lastName && bitrix.lastName !== erp.lastName) changes.lastName = erp.lastName
if (erp.post && bitrix.post !== erp.post) changes.post = erp.post
return changes
}
async function updateInBitrix(pairs) {
let updated = 0
for (const pair of pairs) {
const changes = diff(pair.bitrix, pair.erp)
if (Object.keys(changes).length === 0) continue
// Третий аргумент отключает исключение: одна незаехавшая правка не должна
// ронять весь обмен. Повтор при 429 обёртка всё равно делает.
const body = await apiCall(`${VIBE_URL}/v1/contacts/${pair.bitrix.id}`, {
method: 'PATCH',
headers,
body: JSON.stringify(changes),
}, failed => failed)
if (body?.success) updated++
else console.log(`не обновлён #${pair.bitrix.id}: ${body?.error?.message}`)
}
return updated
}
async function sync() {
const startedAt = new Date().toISOString()
const since = await loadSyncCursor()
console.log(`изменения с ${since}`)
const [bitrixContacts, erpContacts] = await Promise.all([
fetchChangedContacts(since),
fetchErpContacts(since),
])
console.log(`Битрикс24: ${bitrixContacts.length}, учётная система: ${erpContacts.length}`)
const { paired, onlyInBitrix, onlyInErp } = match(bitrixContacts, erpContacts)
// Инкрементальное чтение отвечает на вопрос «что изменилось», но НЕ на вопрос
// «чего не хватает»: отсутствие записи в срезе изменений не означает, что её
// нет в Битрикс24. Поэтому каждую несопоставленную запись доискиваем по ключу
// — без этого обмен создаёт дубль на каждом прогоне.
const trulyNew = []
for (const erp of onlyInErp) {
const found = await findInBitrixByKey(erp)
if (found) paired.push({ bitrix: found, erp })
else trulyNew.push(erp)
}
const created = await createInBitrix(trulyNew)
const updated = await updateInBitrix(paired)
let exported = 0
for (const contact of onlyInBitrix) {
// Та же логика, что и на импорте: отсутствие в срезе изменений не означает
// отсутствия в учётной системе, поэтому сперва доверка по ключу.
if (await findErpContactByKey(bitrixKey(contact))) continue
const { externalKey } = await createErpContact(contact)
// Внешний код возвращается в контакт Битрикс24. Без этой записи контакт
// на следующем запуске снова окажется несопоставленным и выгрузится второй раз.
if (EXTERNAL_KEY_FIELD && externalKey) {
const body = await apiCall(`${VIBE_URL}/v1/contacts/${contact.id}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ [EXTERNAL_KEY_FIELD]: externalKey }),
}, failed => failed)
// Провал этой записи молча ломает сопоставление: контакт выгрузится
// повторно на следующем прогоне, поэтому он не считается выгруженным.
if (!body?.success) {
console.log(` ${contact.id}: внешний код не записан — ${body?.error?.message}`)
continue
}
}
exported++
}
await saveSyncCursor(startedAt)
console.log(`создано ${created.length}, обновлено ${updated}, выгружено ${exported}`)
}
sync().catch(error => {
console.error('обмен прерван:', error.message)
process.exitCode = 1
})
Запуск по расписанию — задача планировщика операционной системы. Одна запись в cron вида 0 * * * * node /opt/sync/erp-sync.js запускает обмен каждый час и переживает перезагрузку сервера.