# Контрагенты из 1С в компании CRM

**Сложность:** продвинутый | **Скоупы:** crm, vibe:onec | **Стек:** cURL / JavaScript

Переносим контрагентов из базы 1С — клиентов и поставщиков — в компании CRM Битрикс24. Новые контрагенты становятся компаниями, у уже перенесённых обновляется название, а повторный запуск не создаёт дублей. Данные идут в одну сторону: скрипт читает 1С, но ничего в ней не меняет.

## Что понадобится

- База 1С, подключённая к Битрикс24, и доступ к её данным — как это настроить, описано в статье [Подключение 1С к Битрикс24](/docs/onec/connect)
- Личный API-ключ Вайбкод `vibe_api_…` со скоупами `crm` и `vibe:onec`
- Пользовательское поле компании типа «строка» для кода контрагента из 1С
- Node.js 18 или новее

Во всех примерах `$VIBE_URL` — базовый адрес `https://vibecode.bitrix24.tech`, `$VIBE_API_KEY` — ваш API-ключ.

## Как устроено решение

1. Связываем компанию CRM с контрагентом 1С по коду контрагента. Код хранится в пользовательском поле компании.
2. Находим в каталоге базы 1С команду со списком контрагентов и её колонки с кодом и наименованием.
3. Читаем контрагентов из 1С страницами.
4. По кодам с этой страницы ищем компании, которые уже есть в CRM.
5. Для новых контрагентов создаём компании, у найденных обновляем название, если оно изменилось.

Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».

## Шаг 1. Поле для кода из 1С

Связывать по названию нельзя: компаний «ООО Север» может быть несколько, а название в 1С может измениться. Нужен код контрагента — он уникален в базе и не меняется. Код хранится в пользовательском поле компании. Имена таких полей на каждом портале свои и выглядят как `ufCrm_1729594209` — список полей возвращает [`GET /v1/companies/fields`](/docs/entities/companies/fields).

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/companies/fields"
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/companies/fields`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data } = await res.json()
const strings = Object.entries(data.fields)
  .filter(([name, f]) => name.startsWith('ufCrm') && f.type === 'string')
  .map(([name, f]) => `${name} — ${f.label}`)
```

```json
{
  "success": true,
  "data": {
    "fields": {
      "id": { "type": "number", "readonly": true, "label": "ID" },
      "title": { "type": "string", "readonly": false, "label": "Название", "description": "Название компании." },
      "ufCrm_1729594209": { "type": "string", "readonly": false, "label": "Код контрагента в 1С" }
    }
  }
}
```

Показаны три поля из полного списка. По подписи в `label` можно найти нужное среди пользовательских полей. Если подходящего поля нет, его создаёт администратор CRM в настройках или вызов [`POST /v1/userfields/companies`](/docs/userfields/crm/create) с `userTypeId: "string"`. Имя поля нужно узнать один раз — скрипт берёт его из переменной `CODE_FIELD`.

## Шаг 2. Команда со списком контрагентов

[`GET /v1/onec/tools`](/docs/onec/tools) возвращает каталог команд базы. Команда со списком контрагентов в разных конфигурациях 1С называется по-разному, поэтому её имя и колонки берите из каталога своей базы. Как читать каталог и выбрать базу, если их несколько, описано в шагах 1 и 2 рецепта [Сводка из 1С в чат Битрикс24](/docs/recipes/onec-report-to-chat).

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/onec/tools"
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/onec/tools`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data: catalog } = await res.json()
const tool = catalog.tools.find(t => t.method === ONEC_METHOD)
const columns = tool.inputSchema.properties.columns.items.enum
```

```json
{
  "success": true,
  "data": {
    "loaded": true,
    "tools": [
      {
        "method": "counterparty.list",
        "title": "Контрагенты",
        "description": "Список контрагентов",
        "inputSchema": {
          "type": "object",
          "properties": {
            "columns": {
              "type": "array",
              "items": { "type": "string", "enum": ["Код", "Наименование", "ИНН"] }
            }
          }
        }
      }
    ]
  }
}
```

Показана одна команда и часть её схемы. Из списка `columns` нужны две колонки: код контрагента и его наименование. Их имена укажите в переменных `ONEC_CODE_COLUMN` и `ONEC_NAME_COLUMN`.

## Шаг 3. Контрагенты из 1С

[`POST /v1/onec/tools/{method}/call`](/docs/onec/call) отправляет запрос на чтение одной страницы, а [`GET /v1/onec/operations/{operationId}`](/docs/onec/operation) возвращает её строки, когда 1С выполнит запрос. В примере — команда `counterparty.list` и колонки `Код` и `Наименование`. Подставьте свои из шага 2.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/onec/tools/counterparty.list/call" \
  -d '{ "columns": ["Код", "Наименование"], "limit": 500, "offset": 0 }'

curl -s -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/onec/operations/cl9x2k4t70000qz8h3m1vb7ry"
```

### JavaScript

```javascript
const queued = await fetch(`${VIBE_URL}/v1/onec/tools/${encodeURIComponent(ONEC_METHOD)}/call`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ columns: [CODE_COLUMN, NAME_COLUMN], limit: 500, offset: 0 }),
}).then(res => res.json())

const result = await waitFor(queued.data.operationId)
```

```json
{
  "success": true,
  "data": {
    "operationId": "cl9x2k4t70000qz8h3m1vb7ry",
    "type": "getData",
    "status": "succeeded",
    "createdAt": "2026-09-14T12:00:00.000Z",
    "finishedAt": "2026-09-14T12:00:04.000Z",
    "expiresAt": "2026-09-14T12:15:00.000Z",
    "result": {
      "columns": ["Код", "Наименование"],
      "rows": [
        { "Код": "000012", "Наименование": "ООО Север" },
        { "Код": "000013", "Наименование": "ООО Юг" }
      ],
      "truncated": true,
      "nextOffset": 2
    }
  }
}
```

Функция `waitFor` проверяет операцию, пока та не завершится, — её код есть в шаге 4 рецепта [Сводка из 1С в чат Битрикс24](/docs/recipes/onec-report-to-chat) и в «Полном коде» ниже. Если `truncated: true`, следующую страницу запрашивают новым вызовом с `offset`, равным `nextOffset`. Если строки закончились ровно на границе `limit`, последняя страница придёт пустой.

## Шаг 4. Компании, которые уже есть в CRM

[`POST /v1/companies/search`](/docs/entities/companies/search) принимает в фильтре массив значений, поэтому один запрос находит компании сразу по нескольким кодам. Скрипт передаёт коды группами по 50. Если у нескольких компаний один код, совпадений бывает больше `limit` — тогда в ответе приходит `meta.hasMore: true`, и следующую страницу запрашивают с `offset`.

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/companies/search" \
  -d '{
    "filter": { "ufCrm_1729594209": ["000012", "000013"] },
    "select": ["id", "title", "ufCrm_1729594209"],
    "limit": 50
  }'
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/companies/search`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filter: { [CODE_FIELD]: codes },
    select: ['id', 'title', CODE_FIELD],
    limit: 50,
  }),
})
const { data: found } = await res.json()
const byCode = new Map(found.map(c => [c[CODE_FIELD], c]))
```

```json
{
  "success": true,
  "data": [
    { "id": 3145, "title": "ООО Север", "ufCrm_1729594209": "000012" }
  ],
  "meta": { "total": 1, "hasMore": false, "durationMs": 277 }
}
```

Код `000013` в ответ не попал — такой компании в CRM ещё нет, её создаст шаг 5.

## Шаг 5. Создание и обновление компаний

Новую компанию создаёт [`POST /v1/companies`](/docs/entities/companies/create). Поля передаются в теле напрямую, без обёртки `fields`, а код записывается в пользовательское поле. Если название в 1С изменилось, его обновляет [`PATCH /v1/companies/:id`](/docs/entities/companies/update).

### cURL

```bash
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/companies" \
  -d '{ "title": "ООО Юг", "ufCrm_1729594209": "000013" }'

curl -s -X PATCH -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/companies/3145" \
  -d '{ "title": "ООО Север-Запад" }'
```

### JavaScript

```javascript
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

await fetch(`${VIBE_URL}/v1/companies`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ title: 'ООО Юг', [CODE_FIELD]: '000013' }),
})

await fetch(`${VIBE_URL}/v1/companies/3145`, {
  method: 'PATCH',
  headers,
  body: JSON.stringify({ title: 'ООО Север-Запад' }),
})
```

Показаны основные поля ответа на создание. Полный список — [Поля компании](/docs/entities/companies/fields).

```json
{
  "success": true,
  "data": {
    "id": 3147,
    "title": "ООО Юг",
    "assignedById": 1,
    "createdTime": "2026-10-14T07:23:27.000Z",
    "ufCrm_1729594209": "000013"
  }
}
```

Создание возвращает `201`, обновление — `200`, в обоих случаях в ответе приходит компания целиком. Сравните код в `data` с отправленным: так видно, что значение записалось в нужное поле.

## Ограничения

**Связь только по коду.** Компанию, созданную в CRM вручную без кода, скрипт не найдёт и создаст рядом ещё одну. Перед первым запуском заполните коды у существующих компаний или начинайте с пустой CRM. Уникальность кода CRM не проверяет: если у двух компаний один код, скрипт обновит первую найденную, а вторую не тронет.

**Одно направление.** Данные идут из 1С в CRM. Если контрагента удалить в 1С, компания в CRM останется. Правки компании в CRM обратно в 1С не попадают. Скрипт обновляет только название. Чтобы переносить другие поля, добавьте их колонки в запрос к 1С и в тела запросов `POST` и `PATCH`.

**Права 1С.** Данные читаются от имени пользователя 1С, назначенного владельцу ключа, поэтому скрипт получает только тех контрагентов, которые доступны этому пользователю в 1С. Если у вас нет доступа к базе, запросы к 1С возвращают `403 ONEC_USER_NOT_MAPPED`. Если 1С не подключена, они возвращают `404 ONEC_NOT_CONNECTED`:

```json
{
  "success": false,
  "error": {
    "code": "ONEC_NOT_CONNECTED",
    "message": "There is no 1C connection."
  }
}
```

**Операция ждёт 15 минут.** Если 1С не выполнила чтение до момента `expiresAt`, операция получает статус `expired`, и скрипт останавливается. Уже обработанные страницы остаются в CRM, а повторный запуск не создаст для них дублей. В режиме [Push-Pull](/docs/onec/connect) 1С проверяет новые запросы по расписанию, поэтому каждая страница может прийти через одну-две минуты.

**Объём данных.** Одна операция возвращает не больше 10 000 строк и 4 МБ данных. Скрипт запрашивает по 500 строк и две колонки — это намного меньше предела.

**Частота запросов.** Компании создаются по одной, и при тысячах контрагентов платформа может ответить `429`. Такой запрос до Битрикс24 не дошёл, поэтому его безопасно повторить. Пауза указана в заголовке `Retry-After`, скрипт её выдерживает. На запросы к 1С отведено 60 вызовов в минуту, на проверку операций — 120. Лимит делится между репликами, поэтому действующее для ключа значение приходит в заголовке `x-ratelimit-limit`. Общие правила — [Лимиты и оптимизация](/docs/optimization).

Полный перечень кодов — [Интеграция 1С](/docs/onec#коды-ошибок) и [Ошибки](/docs/errors).

## Полный код

```javascript
// onec-counterparties.js — контрагенты 1С в компании CRM
//
// Запуск:
//   VIBE_API_KEY=… ONEC_METHOD=counterparty.list ONEC_CODE_COLUMN=Код \
//   ONEC_NAME_COLUMN=Наименование CODE_FIELD=ufCrm_1729594209 node onec-counterparties.js
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
const ONEC_METHOD = process.env.ONEC_METHOD
const CODE_COLUMN = process.env.ONEC_CODE_COLUMN
const NAME_COLUMN = process.env.ONEC_NAME_COLUMN
const CODE_FIELD = process.env.CODE_FIELD                       // пользовательское поле компании под код
const ONEC_INSTANCE_ID = process.env.ONEC_INSTANCE_ID ?? null   // база 1С, если их несколько
const ONEC_FILTERS = process.env.ONEC_FILTERS ? JSON.parse(process.env.ONEC_FILTERS) : null
for (const [name, value] of Object.entries({ VIBE_API_KEY, ONEC_METHOD, CODE_COLUMN, NAME_COLUMN, CODE_FIELD })) {
  if (!value) throw new Error(`Не задана переменная окружения ${name}`)
}

const PAGE = 500
const SEARCH_CHUNK = 50
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
const query = ONEC_INSTANCE_ID ? `?instanceId=${encodeURIComponent(ONEC_INSTANCE_ID)}` : ''
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

// 429 — запрос не принят, повторять безопасно. Пауза из Retry-After, без него растущая.
async function api(path, init = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(`${VIBE_URL}${path}`, { ...init, headers })
    const body = await res.json().catch(() => null)
    if (res.status === 429 && attempt < 5) {
      const advised = Number(res.headers.get('Retry-After'))
      await sleep((Number.isFinite(advised) && advised > 0 ? Math.min(advised, 60) : 2 ** attempt) * 1000)
      continue
    }
    if (!body?.success) {
      throw new Error(`${path}: ${body?.error?.code ?? res.status} ${body?.error?.message ?? ''}`.trim())
    }
    return body
  }
}

async function checkSetup() {
  const { data: fields } = await api('/v1/companies/fields')
  const field = fields.fields?.[CODE_FIELD]
  if (!field) throw new Error(`У компании нет поля ${CODE_FIELD}`)
  if (field.type !== 'string') throw new Error(`Поле ${CODE_FIELD} имеет тип ${field.type}, нужно «строка»`)

  const { data: catalog } = await api(`/v1/onec/tools${query}`)
  if (!catalog.loaded) throw new Error('База ещё не прислала каталог команд — администратору нужно его загрузить')
  const tool = catalog.tools.find(t => t.method === ONEC_METHOD)
  if (!tool) throw new Error(`Команды ${ONEC_METHOD} нет в каталоге базы`)
  const columns = tool.inputSchema?.properties?.columns?.items?.enum ?? []
  for (const c of [CODE_COLUMN, NAME_COLUMN]) {
    if (!columns.includes(c)) throw new Error(`Колонки «${c}» нет в команде. Доступны: ${columns.join(', ')}`)
  }
}

async function waitFor(operationId) {
  let delay = 1000
  for (;;) {
    const { data: op } = await api(`/v1/onec/operations/${operationId}`)
    if (op.status === 'succeeded') return op.result
    if (op.status === 'failed') throw new Error(`1С отказала: ${op.error?.code} ${op.error?.message ?? ''}`)
    if (op.status === 'cancelled') throw new Error('Операция отменена: отключение 1С, смена настроек или потеря доступа')
    if (op.status === 'expired') throw new Error('1С не прислала данные до expiresAt — проверьте, что база в сети')
    await sleep(delay)
    delay = Math.min(delay * 2, 5000)
  }
}

async function readPage(offset) {
  const { data: queued } = await api(`/v1/onec/tools/${encodeURIComponent(ONEC_METHOD)}/call${query}`, {
    method: 'POST',
    body: JSON.stringify({
      columns: [CODE_COLUMN, NAME_COLUMN],
      ...(ONEC_FILTERS ? { filters: ONEC_FILTERS } : {}),
      limit: PAGE,
      offset,
    }),
  })
  return waitFor(queued.operationId)
}

async function findCompanies(codes) {
  const byCode = new Map()
  for (let i = 0; i < codes.length; i += SEARCH_CHUNK) {
    const chunk = codes.slice(i, i + SEARCH_CHUNK)
    // У нескольких компаний может быть один код, поэтому страниц бывает больше одной.
    for (let offset = 0; ; ) {
      const { data, meta } = await api('/v1/companies/search', {
        method: 'POST',
        body: JSON.stringify({ filter: { [CODE_FIELD]: chunk }, select: ['id', 'title', CODE_FIELD], limit: SEARCH_CHUNK, offset }),
      })
      for (const company of data) {
        const code = String(company[CODE_FIELD])
        if (!byCode.has(code)) byCode.set(code, company)
      }
      if (!meta?.hasMore) break
      offset += data.length
    }
  }
  return byCode
}

async function syncPage(rows, stats) {
  // Строки без кода пропускаются: связать их с компанией нечем.
  const items = rows
    .map(row => ({ code: String(row[CODE_COLUMN] ?? '').trim(), title: String(row[NAME_COLUMN] ?? '').trim() }))
    .filter(item => item.code && item.title)
  stats.skipped += rows.length - items.length

  const existing = await findCompanies([...new Set(items.map(i => i.code))])
  for (const item of items) {
    const company = existing.get(item.code)
    if (!company) {
      const { data } = await api('/v1/companies', {
        method: 'POST',
        body: JSON.stringify({ title: item.title, [CODE_FIELD]: item.code }),
      })
      if (String(data[CODE_FIELD]) !== item.code) throw new Error(`Компания ${data.id} создана без кода ${item.code} — проверьте поле ${CODE_FIELD}`)
      existing.set(item.code, data)   // повтор кода на той же странице не создаст вторую компанию
      stats.created++
    } else if (company.title !== item.title) {
      await api(`/v1/companies/${company.id}`, { method: 'PATCH', body: JSON.stringify({ title: item.title }) })
      company.title = item.title
      stats.updated++
    } else {
      stats.unchanged++
    }
  }
}

async function main() {
  await checkSetup()
  const stats = { created: 0, updated: 0, unchanged: 0, skipped: 0 }
  let offset = 0
  for (;;) {
    const result = await readPage(offset)
    await syncPage(result.rows, stats)
    console.log(`Страница с позиции ${offset}: строк ${result.rows.length}`)
    if (!result.truncated || result.nextOffset === null || result.rows.length === 0) break
    offset = result.nextOffset
  }
  console.log(`Создано: ${stats.created}, обновлено: ${stats.updated}, без изменений: ${stats.unchanged}, без кода или названия: ${stats.skipped}`)
}

main().catch(error => {
  console.error(error.message)
  process.exitCode = 1
})
```

## Смотрите также

- [Подключение 1С к Битрикс24](/docs/onec/connect)
- [Интеграция 1С](/docs/onec)
- [Сводка из 1С в чат Битрикс24](/docs/recipes/onec-report-to-chat)
- [Синхронизация контактов с 1С или ERP](/docs/recipes/erp-sync)
- [Поиск компаний](/docs/entities/companies/search)
- [Создать компанию](/docs/entities/companies/create)
