> ## Documentation Index
> Fetch the complete documentation index at: https://docs.exode.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Синхронизация с HR-системой

> Сквозной сценарий синхронизации оргструктуры и сотрудников из 1С, CRM или другой HR-системы в Exode

Этот гайд описывает типовой сценарий выгрузки сотрудников и оргструктуры из внешней системы (1С:ЗУП, CRM,
другая LMS) в Exode: порядок вызовов, маппинг типовых полей и куда передавать данные, для которых нет
прямого поля в API.

<Info>
  Ключевой принцип: все сущности связываются через **внешние идентификаторы** (`extId`) — GUID или коды из вашей
  системы. Вам не нужно хранить внутренние ID Exode: департаменты, должности и трудоустройства адресуются по
  `extId`, пользователи находятся по `extId` через [`user/find`](/ru/exode-api/school/user/find) и
  [`user/upsert`](/ru/exode-api/school/user/upsert).
</Info>

<Card title="1С → Exode: готовый пример синхронизации" icon="file-code" horizontal href="/ru/exode-api/school/integrations/examples/1c-staff-sync">
  Полный разобранный пример под 1С:ЗУП: BSL-код цикла выгрузки (update по extId → NotFound → create),
  JSON-маппинг полей 1-в-1, домены из логинов, должности по GUID и предусловия.
</Card>

## Схема связей

Как связаны сущности модуля staff между собой:

```mermaid theme={null}
flowchart TD
    School[Школа / School] --> Department[Департамент / Department]
    Department -->|parentId| Department
    School --> Position[Должность / Position]
    School --> User[Пользователь / User]
    User --> Employment[Трудоустройство / Employment]
    Department --> Employment
    Position --> Employment
    Employment --> Absence[Отсутствие / Absence]
    Department --> Manager[Руководитель / DepartmentManager]
    Employment --> Manager
```

* **Департамент** — подразделение школы; иерархия строится через `parentId` (`parentExtId`).
* **Трудоустройство** — центральная связка: пользователь + департамент + должность, плюс условия
  занятости (`kind`, `type`, `rate`).
* **Руководитель департамента** привязывается не к пользователю напрямую, а к его **активному
  трудоустройству**.
* **Отсутствие** (отпуск, больничный, командировка) тоже привязано к трудоустройству.

## Порядок синхронизации

<Steps>
  <Step title="Департаменты">
    Создайте оргструктуру методами раздела [Департаменты](/ru/exode-api/school/staff/department). Передавайте
    `extId` (код подразделения из вашей системы) и `parentExtId` для иерархии.

    Родительский департамент должен существовать на момент создания дочернего — синхронизируйте дерево
    **сверху вниз** (или отсортируйте выгрузку так, чтобы родители шли раньше детей).

    ```json theme={null}
    { "name": "Отдел продаж", "extId": "ПОДР-001", "parentExtId": "ПОДР-000" }
    ```

    <Warning>
      Департаменты и должности должны быть заведены **до** синхронизации сотрудников. Если при создании сотрудника
      `departmentExtId` не найден — вернётся `StaffDepartmentNotFound` и **пользователь создан не будет** (аналогично
      `StaffPositionNotFound`).
    </Warning>
  </Step>

  <Step title="Должности">
    Создайте должности методами раздела [Должности](/ru/exode-api/school/staff/position) — `name` и `extId`.
    **Правильный ключ — GUID должности из вашей системы (`extId`), а не имя**: имя изменяемо и не уникально на
    вашей стороне (переименовали должность — и резолв по имени сломается). Актуализируйте имя при каждой
    синхронизации через `PUT /staff/position/ext/{extId}/update`, а при отсутствии — создавайте
    (`StaffPositionNotFound` → `POST /staff/position/create`).

    <Warning>
      Имя должности **уникально в рамках школы**: две должности с одинаковым именем, но разными `extId` (например,
      из разных организаций в 1С) создать нельзя — вернётся `StaffPositionNameIsNotUniq`. Сведите GUID'ы к одному
      либо не передавайте GUID у дублирующей организации.
    </Warning>

    Если у должностей вообще нет кодов (в выгрузке только `job_position_name`) — работает fallback: найдите
    должность по имени через `GET /staff/position/list?search=...`, при отсутствии создайте.
  </Step>

  <Step title="Сотрудники">
    Создать нового сотрудника можно методом `create` или `upsert` (существующих обновляют через `update`). Что
    выбрать, зависит от вашей выгрузки — в первую очередь от того, нужно ли задавать пароль: подробно в разделе
    [Каким методом заводить сотрудника](#каким-методом-заводить-сотрудника). Трудоустройства во всех случаях
    передаются массивом в `extra.staff.employments` (каждый элемент — `positionExtId` + `departmentExtId`,
    опционально `extId`, `type`, `kind`, `startAt`, `rate`). Для корпоративной школы при **создании** пользователя
    массив обязателен и должен содержать хотя бы одно трудоустройство (иначе `StaffEmploymentInputRequired`).

    Если сотрудник числится в **нескольких подразделениях** — передайте несколько элементов в
    `extra.staff.employments` (от 1 до 10): первое трудоустройство с `kind: "Main"`, остальные —
    `kind: "InternalSecondary"` (или `ExternalSecondary` для внешнего совместительства).

    <Warning>
      `extra.staff.employments` умеет только **«убедиться, что назначение есть»** (нет — нанять, есть —
      пропустить). Это **не полный список для синхронизации**: см. раздел
      [Поведение назначений employments](#поведение-назначений-employments) ниже — важные нюансы про переводы,
      увольнения и отсутствующие в массиве назначения.
    </Warning>
  </Step>

  <Step title="Руководители">
    Назначьте руководителей департаментов методом
    [`staff/department-manager/set`](/ru/exode-api/school/staff/department-manager): `departmentExtId` +
    `employmentExtId` (внешний идентификатор трудоустройства руководителя, который вы передали при найме).
    Основной руководитель помечается `isPrimary: true`.

    Если в вашей системе руководитель указывается у каждого сотрудника (например, `leader_external_ids`), а не у
    подразделения — назначайте руководителем департамента и/или сохраните ссылку на руководителя в кастомном поле
    сотрудника.
  </Step>

  <Step title="Остальные данные — кастомные поля">
    Для данных, которым нет поля в схеме пользователя (отчество, город, теги, произвольный статус из CRM,
    дополнительный email), создайте кастомные поля в [макете формы](/ru/exode-api/school/form-layout/create) и записывайте значения
    через [`custom-field/value/set-by-slug`](/ru/exode-api/school/custom-field/set) после апсерта пользователя.
  </Step>

  <Step title="Увольнения">
    При увольнении вызовите [`staff/employment/terminate`](/ru/exode-api/school/staff/employment#уволить-сотрудника)
    (по `extId` трудоустройства). Закрытие **последнего активного** трудоустройства автоматически переводит
    пользователя в статус `Terminated` и закрывает ему доступ — отдельно блокировать его не нужно. Если у
    сотрудника остаются другие активные трудоустройства, увольнение из одного из них — это структурное изменение,
    доступ сохраняется. Чтобы заблокировать доступ **без увольнения**, передайте `status: "Blocked"` в
    [`user/update`](/ru/exode-api/school/user/update) — все активные сессии будут завершены; см.
    [Жизненный цикл статуса](#жизненный-цикл-статуса-пользователя).

    Если ваша система отдаёт **полный список** сотрудников (а не изменения) — получите текущие активные
    трудоустройства через [`staff/employment/list`](/ru/exode-api/school/staff/employment), сравните с выгрузкой по
    `extId` и увольте отсутствующих.
  </Step>
</Steps>

## Каким методом заводить сотрудника

Создать нового сотрудника умеют только `create` и `upsert`. `update` пользователя **не создаёт** — он лишь
обновляет существующего (и участвует в паттерне «update → NotFound → create»). Выбор между `create` и `upsert`
зависит от того, нужно ли вам задавать пароль.

* **`POST /user/create` — создаёт нового.** Если пользователь с таким логином (`email`/`phone`/`domain`), `tgId`
  или `extId` уже существует — вернётся ошибка (`EmailIsBusy`, `PhoneIsBusy`, `DomainIsBusy`, `TgIdIsBusy`,
  `ExtIdIsBusy`). Пароль (`password`) применяется.
* **`PUT /user/upsert` — «создать или обновить» за один вызов.** Ищет существующего по логину / `tgId` / `extId`:
  найден → обновляет, нет → создаёт. Идемпотентен — повторная выгрузка того же сотрудника не создаёт дубль и не
  падает.
* **`PUT /user/ext/{extId}/update` (или `PUT /user/{userId}/update`) — только обновляет существующего.** Если
  пользователя нет — `NotFound` (создания не будет). Пароль этим методом **не задаётся**.

<Warning>
  **Пароль применяется только при создании** — в `create` и в ветке создания `upsert`. При **обновлении** (в том
  числе когда `upsert` нашёл существующего пользователя) `password` игнорируется: метод `update` его не
  поддерживает.
</Warning>

**Как выбрать:**

* **Пароль вы не задаёте** (генерируете и рассылаете на нашей стороне или он не нужен) → берите **`upsert`**: один
  вызов, идемпотентно, проще всего для регулярной синхронизации.
* **Пароль задаёте вы** (например, детерминированный «имя+фамилия+год») → заводите новых через **`create`** (там
  пароль точно применится), а существующих обновляйте через **`update`**. Типовой паттерн:
  **`update` по `extId` → при `NotFound` → `create`** (всё адресуется по `extId`, внутренние ID Exode не нужны).
  Через `upsert` так не выйдет: если пользователь уже есть, `upsert` уйдёт в обновление и заданный вами пароль
  не применится.

## Поведение назначений employments

Массив `employments` в `user/create`, `user/update` и `user/upsert` применяется **идемпотентно** и умеет ровно
одно — «донанять, если такого активного назначения ещё нет». Это ключевое отличие от «полной синхронизации»,
и его легко упустить:

* **Повтор той же пары департамент + должность — безопасный no-op.** Слать одно и то же назначение при каждом
  синке можно — дубль не создастся.
* **Новая пара — это новое назначение (совместительство), а не перевод.** Если сотрудника в 1С перевели и вы
  пришлёте в `employments` новую пару, у него станет **два активных назначения** (старое и новое), а не одно.
* **Отсутствие назначения в массиве ≠ увольнение.** Не пришли назначение — с ним ничего не произойдёт, оно
  останется активным. Убрать его можно только явным `terminate`.

Поэтому кадровые изменения существующего сотрудника выражаются **не через `extra`**, а отдельными эндпоинтами
раздела [Трудоустройства](/ru/exode-api/school/staff/employment):

| Событие в HR                        | Эндпоинт                                                  |
| ----------------------------------- | --------------------------------------------------------- |
| Перевод в другой департамент        | `POST /staff/employment/ext/{extId}/transfer`             |
| Смена должности (повышение)         | `POST /staff/employment/ext/{extId}/promote`              |
| Увольнение с назначения             | `POST /staff/employment/ext/{extId}/terminate`            |
| Новое назначение / совместительство | `POST /staff/employment/hire` (или элемент `employments`) |

<Info>
  Практический вывод для инкрементальной выгрузки: в `user/ext/{extId}/update` для существующего сотрудника
  `extra.staff` обычно **не передают** — иначе перевод в 1С превратится в лишнее совместительство. Донайм через
  `extra` уместен, когда вы осознанно добавляете сотруднику ещё одно место работы.
</Info>

## Жизненный цикл статуса пользователя

Поле `user.status` (`Active` / `OnLeave` / `Banned` / `Blocked` / `Terminated` / `Deleted`) частично управляется
автоматически. Доступ закрывают `Banned`, `Blocked`, `Terminated` и `Deleted`; `Active` и `OnLeave` — оставляют
вход открытым.

* **Увольнение с последнего активного трудоустройства** (`employment/terminate`) автоматически переводит
  пользователя в `Terminated`: вход запрещён, все сессии завершаются. **Повторный наём** (`hire`) сотрудника со
  статусом `Terminated` автоматически возвращает `Active`. Нельзя уволить с последнего назначения самого себя
  (`StaffCannotTerminateSelf`) или владельца школы (`StaffCannotTerminateSchoolOwner`).
* **Блокировка админом** — `user/update { "status": "Blocked" }`: вход закрыт, сессии завершаются, новые
  зачисления на продукты запрещены; данные сохраняются и видны в отчётах. `{ "status": "Active" }` снимает
  блокировку. Владельца школы заблокировать нельзя.
* **`OnLeave` управляется модулем [отсутствий](/ru/exode-api/school/staff/absence):** при действующем отсутствии
  (отпуск/больничный) статус автоматически становится `OnLeave`, по завершении — возвращается в `Active`.
  `OnLeave` информационный — **вход остаётся доступен**, вручную его ставить не нужно.
* **Бан и разбан** — `user/update { "status": "Banned" }` закрывает доступ. При снятии бана (передайте `Active`)
  фактический статус пересчитывается автоматически по трудоустройствам и отсутствиям: уволенный вернётся в
  `Terminated`, находящийся в отпуске — в `OnLeave`, остальные — в `Active`.

<Info>
  Синхронизация `OnLeave` работает и **по календарю**: помимо пересчёта при API-вызовах, платформа раз в час
  автоматически переводит сотрудников в `OnLeave` при наступлении даты начала отсутствия и возвращает в `Active`
  по его окончании. Заводить собственный периодический пересчёт больше не нужно.
</Info>

<Info>
  Маппинг статуса из выгрузки: «Работает» → `Active` (снимет ранее выставленную блокировку). «Уволен»
  **не передаётся статусом** — увольнение оформляется через `employment/terminate`, `Terminated` выставится сам.
</Info>

## Маппинг типовых полей

Тело запроса `user/create` или `user/upsert` с комментариями — откуда какое поле берётся и что важно знать
(`// ←` показывает исходное поле вашей выгрузки):

```jsonc theme={null}
{
  "email": "ivanov@company.ru",         // ← email. Поле для входа (логин)
  "phone": "+998901234567",             // ← телефон, межд. формат. Поле для входа (логин)
  "domain": "i.ivanov.01011990",        // ← ваш логин. ВХОД ВЫПОЛНЯЕТСЯ ПО НЕМУ. Латиница/цифры/_/точки-разделители,
                                        //   ≤65, уникален в школе. Нельзя одни цифры или вид id123
  "extId": "i.ivanov.01011990",         // ← GUID/внешний ID из вашей системы. Ключ синхронизации, НЕ логин
  "password": "SecurePass123",          // ← если не передать и есть email/телефон — сгенерируем и отправим сами.
                                        //   Применяется только при СОЗДАНИИ (см. create vs upsert ниже)
  "status": "Active",                   // ← «Работает» → Active. «Уволен» — не здесь, а через employment/terminate
  "profile": {
    "firstName": "Иван",                // ← имя. ≤15 символов, буквы/пробелы/апострофы
    "lastName": "Иванов",               // ← фамилия. ≤15 символов, буквы/пробелы/апострофы
                                        // ← отчество — отдельного поля НЕТ → в кастомное поле
    "bdate": "1990-01-01",              // ← дата рождения. ISO datetime → только дата YYYY-MM-DD
    "sex": "Men",                       // ← пол: «м» → Men, «ж» → Women
    "role": "Student"                   // ← роль: Student / Tutor / Parent
  },
  "extra": {
    "staff": {
      "employments": [                  // ← 1..10 трудоустройств (несколько = совместительство)
        {
          "departmentExtId": "ПОДР-001",                           // ← код подразделения (создайте заранее)
          "positionExtId": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01", // ← GUID должности (создайте заранее). Имя — не ключ
          "kind": "Main",                                          // ← Main; доп. — InternalSecondary/ExternalSecondary
          "startAt": "2020-03-01T00:00:00Z"                        // ← дата приёма/назначения (ISO 8601)
        }
      ]
    }
  }
}
```

Поля, которым **нет прямого соответствия** в API:

* **Отчество, город, теги, произвольный статус из CRM, доп. email** → [кастомные поля](/ru/exode-api/school/custom-field/set)
  (`custom-field/value/set-by-slug` после создания/апсерта пользователя).
* **Руководители** (`leader_external_ids`) → отдельный вызов
  [`staff/department-manager/set`](/ru/exode-api/school/staff/department-manager) (`departmentExtId` + `employmentExtId`).
* **Роль/теги без аналога** — игнорируются.

## Ограничения, о которых стоит знать заранее

<Warning>
  * **`domain` — до 65 символов**, латинские буквы, цифры и `_`; точки допускаются как разделители сегментов
    (не в начале/конце и не подряд). Логины вида `i.ivanov.01011990` передавать можно. Нельзя домен из одних
    цифр или вида `id123`. Сервер приводит к нижнему регистру; значение **уникально в рамках школы** — занятое
    вернёт `DomainIsBusy`.
  * **Сотрудник без email и телефона** входит только по `domain` — для него **обязательно передайте `password`**,
    иначе войти будет нечем.
  * **`firstName` и `lastName` — до 15 символов**, буквы (латиница/кириллица), пробелы и апострофы. Длинные ФИО
    обрезайте на своей стороне.
  * **`extId` любой сущности** (пользователь, департамент, должность, трудоустройство, отсутствие,
    руководитель) — до 50 символов, **без `/` и пробелов** (используется в путях `ext/{extId}`); точки
    допустимы. `user.extId` — не логин.
  * **По `extId` и `tgId` нельзя авторизоваться** — поля для входа: `email`, `phone`, `domain`.
  * Модуль трудоустройств (`staff`) доступен только для **корпоративных школ**; для них массив
    `extra.staff.employments` (от 1 до 10 трудоустройств) обязателен при создании пользователя.
</Warning>

## Минимальный пример: сотрудник с трудоустройством

```bash cURL theme={null}
curl --location --request PUT 'https://api.exode.biz/saas/v2/user/upsert' \
  --header 'Seller-Id: {{ sellerId }}' \
  --header 'School-Id: {{ schoolId }}' \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_TOKEN' \
  --data-raw '{
    "email": "ivanov@company.ru",
    "domain": "i.ivanov.01011990",
    "extId": "i.ivanov.01011990",
    "status": "Active",
    "profile": {
      "firstName": "Иван",
      "lastName": "Иванов",
      "bdate": "1990-01-01",
      "sex": "Men"
    },
    "extra": {
      "staff": {
        "employments": [
          {
            "positionExtId": "manager",
            "departmentExtId": "ПОДР-001",
            "kind": "Main",
            "type": "FullTime",
            "startAt": "2020-03-01T00:00:00Z"
          }
        ]
      }
    }
  }'
```

После апсерта запишите нестандартные атрибуты в кастомные поля:

```bash cURL theme={null}
curl --location 'https://api.exode.biz/saas/v2/form/custom-field/value/set-by-slug' \
  --header 'Seller-Id: {{ sellerId }}' \
  --header 'School-Id: {{ schoolId }}' \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_TOKEN' \
  --data-raw '{
    "userId": 1683,
    "layoutId": 12,
    "values": [
      { "slug": "middle_name", "value": "Иванович" },
      { "slug": "city", "value": "Ташкент" },
      { "slug": "crm_status", "value": "Работает" }
    ]
  }'
```

***

*Обновлено: 2026-07-14 22:58 UTC*
