> ## 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.

# Пример синхронизации

> Полный пример синхронизации сотрудников из 1С в Exode: создание и обновление (2-в-1), кадровые изменения, BSL-код и JSON-маппинг 1-в-1

Вход — JSON выгрузки из HR-системы (структура клиента, как есть):

```json theme={null}
{
  "partial": false,
  "items": [
    {
      "last_name": "Иванов",
      "middle_name": "Иванович",
      "first_name": "Иван",
      "email": "ivanov@company.ru",
      "job_position_name": "Менеджер",
      "job_position_external_id": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01",
      "department_external_ids": ["ПОДР-001"],
      "city": null,
      "status": "Работает",
      "gender": "м",
      "external_id": "i.ivanov.01011990",
      "leader_external_ids": ["p.petrov.15051980"],
      "role": "user",
      "date_of_hire": "2020-03-01T00:00:00Z",
      "date_of_hire_job_poition": "2020-03-01T00:00:00Z",
      "tag_list": null,
      "email_for_notifications": "",
      "date_of_birth": "1990-01-01T00:00:00Z",
      "login": "i.ivanov.01011990",
      "password": null
    }
  ]
}
```

Каждый item обрабатывается так: `PUT /saas/v2/user/ext/{extId}/update` (обновление карточки
по внешнему идентификатору) — если сотрудника нет (`NotFound`), `POST /saas/v2/user/create`
(создаёт пользователя **и** нанимает его — массив `extra.staff.employments`). Пользователь,
подразделение и должность адресуются по extId напрямую — внутренние id нашей системы не нужны
вообще. `extra` — контейнер сопутствующих сущностей: сейчас `staff.employments`, позже —
значения кастомных полей и т.п.

```bsl theme={null}
// ============================================================
// Загрузка сотрудников в Exode: update по extId → NotFound → create (2-в-1)
// ============================================================

Процедура ЗагрузитьСотрудников(СтрокаJSONВыгрузки) Экспорт

	ЧтениеJSON = Новый ЧтениеJSON;
	ЧтениеJSON.УстановитьСтроку(СтрокаJSONВыгрузки);
	Выгрузка = ПрочитатьJSON(ЧтениеJSON, Ложь);

	Для Каждого Сотрудник Из Выгрузка.items Цикл

		// ---------- профиль ----------
		Профиль = Новый Структура;
		Профиль.Вставить("firstName", Сотрудник.first_name);   // "Иван"
		Профиль.Вставить("lastName",  Сотрудник.last_name);    // "Иванов"
		// middle_name ("Иванович") — отдельного поля нет, при необходимости:
		// Профиль.Вставить("firstName", Сотрудник.first_name + " " + Сотрудник.middle_name);

		Если ЗначениеЗаполнено(Сотрудник.date_of_birth) Тогда
			Профиль.Вставить("bdate", Лев(Сотрудник.date_of_birth, 10)); // "1990-01-01"
		КонецЕсли;

		Если Сотрудник.gender = "м" Тогда
			Профиль.Вставить("sex", "Men");
		ИначеЕсли Сотрудник.gender = "ж" Тогда
			Профиль.Вставить("sex", "Women");
		КонецЕсли;

		// ---------- назначения (staff — МАССИВ, поддерживает совместительство) ----------
		// В Corporate-школе минимум одно назначение ОБЯЗАТЕЛЬНО при создании —
		// без него будет ошибка StaffEmploymentInputRequired
		Назначение = Новый Структура;
		Назначение.Вставить("departmentExtId", Сотрудник.department_external_ids[0]); // "ПОДР-001"

		// extId назначения — ВАЖНО задать: по нему потом адресуются кадровые изменения
		// (transfer/promote/terminate — см. «Что делать, когда в 1С что-то изменилось»)
		Назначение.Вставить("extId", Сотрудник.external_id + ":" + Сотрудник.department_external_ids[0]);

		// startAt = дата назначения на должность; если её нет — дата приёма в компанию
		Если ЗначениеЗаполнено(ПолучитьЗначение(Сотрудник, "date_of_hire_job_poition")) Тогда
			Назначение.Вставить("startAt", Сотрудник.date_of_hire_job_poition);
		Иначе
			Назначение.Вставить("startAt", Сотрудник.date_of_hire);
		КонецЕсли;

		// Должность: правильный способ — GUID из 1С (extId), имя — изменяемое и не уникальное.
		// GUID необязателен: если его нет (старый формат выгрузки) — fallback на резолв по имени
		ГуидДолжности = ПолучитьЗначение(Сотрудник, "job_position_external_id"); // GUID или Неопределено

		Если ЗначениеЗаполнено(ГуидДолжности) Тогда
			ОбеспечитьДолжностьПоГуид(ГуидДолжности, Сотрудник.job_position_name);
			Назначение.Вставить("positionExtId", ГуидДолжности);
		Иначе
			Назначение.Вставить("positionId", ИдДолжностиПоИмени(Сотрудник.job_position_name));
		КонецЕсли;

		Штат = Новый Массив;
		Штат.Добавить(Назначение);
		// Совместительство: второе назначение — ещё один элемент массива
		// с kind = "InternalSecondary" и своими department/position

		// ---------- пользователь ----------
		Тело = Новый Структура;
		Тело.Вставить("extId",   Сотрудник.external_id);  // "i.ivanov.01011990" — ключ синхронизации
		Тело.Вставить("profile", Профиль);

		// Логин: есть email — входит по email; нет — передаём domain,
		// чтобы сотрудник мог входить по нему (логин+пароль)
		Если ЗначениеЗаполнено(Сотрудник.email) Тогда
			Тело.Вставить("email", Сотрудник.email);
		Иначе
			Тело.Вставить("domain", ДоменИзЛогина(Сотрудник.login)); // "i.ivanov.01011990" → "i.ivanov.01011990" (как есть)
		КонецЕсли;

		Если ЗначениеЗаполнено(Сотрудник.password) Тогда
			Тело.Вставить("password", Сотрудник.password);
		КонецЕсли;

		// status "Работает" → "Active" (снимет ранее выставленную блокировку).
		// "Уволен" НЕ мапится на статус — увольнение идёт через employment/terminate
		// (статус Terminated выставится автоматически, см. предусловия)
		Если Сотрудник.status = "Работает" Тогда
			Тело.Вставить("status", "Active");
		КонецЕсли;

		// city, role, tag_list, email_for_notifications, date_of_hire_job_poition,
		// leader_external_ids — в API не передаются
		// (руководители назначаются отдельным вызовом department-manager/set)

		// ---------- UPDATE: карточка существующего (по extId, без внутренних id) ----------
		// (кадровые изменения — через /staff/employment/*, см. предусловия)
		Ответ = ВызватьAPI("PUT",
			"/saas/v2/user/ext/" + КодироватьСтроку(Сотрудник.external_id, СпособКодированияСтроки.КодировкаURL) + "/update",
			Тело);

		Если Ответ.КодОтвета >= 300 И Ответ.Причина = "NotFound" Тогда

			// ---------- CREATE: пользователь + наём (2-в-1) ----------
			// Сопутствующие сущности — в extra (staff.employments сейчас, form fills и т.п. позже)
			СтаффЭкстра = Новый Структура;
			СтаффЭкстра.Вставить("employments", Штат);

			Экстра = Новый Структура;
			Экстра.Вставить("staff", СтаффЭкстра);

			Тело.Вставить("extra", Экстра);

			Ответ = ВызватьAPI("POST", "/saas/v2/user/create", Тело);

		КонецЕсли;

		Если Ответ.КодОтвета >= 300 Тогда
			ВызватьИсключение "Сотрудник " + Сотрудник.external_id + ": " + Ответ.Причина;
		КонецЕсли;

	КонецЦикла;

КонецПроцедуры

// Domain допускает латиницу, цифры, "_" и точки (точка — только внутри,
// не первой/последней и не подряд) — логины вида "i.ivanov.01011990" проходят как есть,
// остальные символы заменяем на "_"
Функция ДоменИзЛогина(Логин)

	Разрешённые = "abcdefghijklmnopqrstuvwxyz0123456789_.";
	Результат   = "";

	Для Номер = 1 По СтрДлина(Логин) Цикл
		Символ = НРег(Сред(Логин, Номер, 1));
		Результат = Результат + ?(СтрНайти(Разрешённые, Символ) > 0, Символ, "_");
	КонецЦикла;

	// Точки по краям и подряд недопустимы
	Пока СтрНайти(Результат, "..") > 0 Цикл
		Результат = СтрЗаменить(Результат, "..", ".");
	КонецЦикла;

	Результат = ?(Лев(Результат, 1) = ".", Сред(Результат, 2), Результат);
	Результат = ?(Прав(Результат, 1) = ".", Лев(Результат, СтрДлина(Результат) - 1), Результат);

	Возврат Результат;

КонецФункции

// Должность по GUID (правильный способ): обновляем имя по extId,
// не найдена — создаём с extId = GUID. Внутренние id не нужны вообще.
Процедура ОбеспечитьДолжностьПоГуид(Гуид, Наименование)

	Ответ = ВызватьAPI("PUT",
		"/saas/v2/staff/position/ext/" + Гуид + "/update",
		Новый Структура("name", Наименование)); // имя изменяемое — актуализируем при каждом синке

	Если Ответ.КодОтвета < 300 Тогда
		Возврат;
	КонецЕсли;

	Если Ответ.Причина <> "StaffPositionNotFound" Тогда
		ВызватьИсключение "Должность " + Гуид + ": " + Ответ.Причина;
	КонецЕсли;

	Тело = Новый Структура;
	Тело.Вставить("name",  Наименование);
	Тело.Вставить("extId", Гуид);

	Ответ = ВызватьAPI("POST", "/saas/v2/staff/position/create", Тело);

	Если Ответ.КодОтвета >= 300 Тогда
		// StaffPositionNameIsNotUniq: должность с таким именем уже есть под ДРУГИМ GUID —
		// имя уникально в рамках школы, сведите GUID'ы организаций к одному на своей стороне
		ВызватьИсключение "Должность «" + Наименование + "»: " + Ответ.Причина;
	КонецЕсли;

КонецПроцедуры

// Fallback для выгрузок без GUID: ищем по имени, нет — создаём
Функция ИдДолжностиПоИмени(Наименование)

	Ответ = ВызватьAPI("GET",
		"/saas/v2/staff/position/list?search=" + КодироватьСтроку(Наименование, СпособКодированияСтроки.КодировкаURL));

	Для Каждого Должность Из Ответ.Тело.items Цикл
		Если НРег(Должность.name) = НРег(Наименование) Тогда
			Возврат Должность.id;
		КонецЕсли;
	КонецЦикла;

	Ответ = ВызватьAPI("POST", "/saas/v2/staff/position/create",
		Новый Структура("name", Наименование));

	Возврат Ответ.Тело.id;

КонецФункции

// Безопасное чтение необязательного поля выгрузки
Функция ПолучитьЗначение(Структура, Имя)

	Значение = Неопределено;
	Структура.Свойство(Имя, Значение);

	Возврат Значение;

КонецФункции

// HTTP-обвязка
Функция ВызватьAPI(Метод, Путь, ТелоЗапроса = Неопределено)

	Соединение = Новый HTTPСоединение("api.exode.biz", 443,,,, 30, Новый ЗащищенноеСоединениеOpenSSL());

	Запрос = Новый HTTPЗапрос(Путь);
	Запрос.Заголовки.Вставить("Authorization", "Bearer <AUTH_TOKEN>");
	Запрос.Заголовки.Вставить("school-id",     "<SCHOOL_ID>");
	Запрос.Заголовки.Вставить("seller-id",     "<SELLER_ID>");
	Запрос.Заголовки.Вставить("Content-Type",  "application/json");

	Если ТелоЗапроса <> Неопределено Тогда
		ЗаписьJSON = Новый ЗаписьJSON;
		ЗаписьJSON.УстановитьСтроку();
		ЗаписатьJSON(ЗаписьJSON, ТелоЗапроса);
		Запрос.УстановитьТелоИзСтроки(ЗаписьJSON.Закрыть(), КодировкаТекста.UTF8);
	КонецЕсли;

	Ответ = Соединение.ВызватьHTTPМетод(Метод, Запрос);

	Результат = Новый Структура("КодОтвета, Тело, Причина", Ответ.КодСостояния, Неопределено, "");

	СтрокаОтвета = Ответ.ПолучитьТелоКакСтроку();

	Если ЗначениеЗаполнено(СтрокаОтвета) Тогда
		ЧтениеJSON = Новый ЧтениеJSON;
		ЧтениеJSON.УстановитьСтроку(СтрокаОтвета);
		Результат.Тело = ПрочитатьJSON(ЧтениеJSON, Ложь);
	КонецЕсли;

	Если Результат.КодОтвета >= 400 И Результат.Тело <> Неопределено И Результат.Тело.Свойство("cause") Тогда
		Результат.Причина = Результат.Тело.cause; // например StaffDepartmentNotFound
	КонецЕсли;

	Возврат Результат;

КонецФункции
```

## Что реально уходит в API на Иванова: маппинг 1-в-1

Входной item из выгрузки:

```json theme={null}
{
    "last_name": "Иванов",
    "middle_name": "Иванович",
    "first_name": "Иван",
    "email": "ivanov@company.ru",
    "job_position_name": "Менеджер",
    "job_position_external_id": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01",
    "department_external_ids": ["ПОДР-001"],
    "city": null,
    "status": "Работает",
    "gender": "м",
    "external_id": "i.ivanov.01011990",
    "leader_external_ids": ["p.petrov.15051980"],
    "role": "user",
    "date_of_hire": "2020-03-01T00:00:00Z",
    "date_of_hire_job_poition": "2020-03-01T00:00:00Z",
    "tag_list": null,
    "email_for_notifications": "",
    "date_of_birth": "1990-01-01T00:00:00Z",
    "login": "i.ivanov.01011990",
    "password": null
}
```

Что уходит в API (новый сотрудник — создание + наём одним вызовом):

```jsonc theme={null}
// POST /saas/v2/user/create
// Authorization: Bearer <AUTH_TOKEN>
// school-id: <SCHOOL_ID>
// seller-id: <SELLER_ID>

{
    "extId": "i.ivanov.01011990",               // ← external_id (ключ синхронизации)
    "email": "ivanov@company.ru",               // ← email (если есть)
    "domain": "i.ivanov.01011990",              // ← login — ТОЛЬКО если email пустой (см. вариант ниже)
    "password": "<пароль>",                     // ← можете передать i.ivanov.01011990 либо придумать свой, либо не передавать
                                                //   если не передадите password и передадите email - пароль мы вышлем сами
    "status": "Active",                         // ← status: "Работает" → "Active"; "Уволен" → НЕ статусом,
                                                //   а через employment/terminate (Terminated выставится сам)

    "profile": {
        "firstName": "Иван",                    // ← first_name
        "lastName": "Иванов",                   // ← last_name
                                                // ← middle_name ("Иванович") — отдельного поля нет;
                                                //   при желании: "firstName": "Иван Иванович"
        "bdate": "1990-01-01",                  // ← date_of_birth (только дата, без времени)
        "sex": "Men"                            // ← gender: "м" → "Men", "ж" → "Women"
    },

    "extra": {
        "staff": {
            "employments": [
                {
                    "extId": "i.ivanov.01011990:ПОДР-001",                      // ← ключ назначения (external_id + код подразделения);
                                                                                //   по нему потом transfer/promote/terminate
                    "departmentExtId": "ПОДР-001",                              // ← department_external_ids[0]
                    "positionExtId": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01",    // ← job_position_external_id (GUID);
                                                                                //   job_position_name — только имя для создания должности
                    "startAt": "2020-03-01T00:00:00Z"                           // ← date_of_hire_job_poition (или date_of_hire)
                }
            ]
        }
    }
}

// НЕ передаются (нет соответствия в API):
//   city, role, tag_list, email_for_notifications
// Отдельными вызовами:
//   leader_external_ids → POST /staff/department-manager/set (руководитель подразделения)
//   date_of_hire (приём в компанию) — если отличается от даты назначения,
//   в API живёт только startAt назначения
```

Ответ: `{ "user": { ... } }` — пользователь создан и нанят в «ПОДР-001»
на должность «Менеджер» одним вызовом.

Существующий сотрудник — обновление карточки по extId, внутренний id не нужен
(если пользователя нет — вернётся `NotFound`, тогда идём в create):

```http theme={null}
PUT /saas/v2/user/ext/i.ivanov.01011990/update

{
    "email": "ivanov@company.ru",
    "status": "Active",
    "profile": {
        "firstName": "Иван",
        "lastName": "Иванов",
        "bdate": "1990-01-01",
        "sex": "Men"
    }
}
```

Без `extra.staff` — намеренно: повторная передача назначения безвредна (идемпотентный no-op),
но если сотрудника в 1С **перевели** в другое подразделение, `extra` создаст ему второе
назначение (совместительство), а не перевод. Кадровые изменения — через `/staff/employment/*`.

`extra` в update тоже поддерживается — тот же формат, что и в create. Разберём, что произойдёт
с каждым элементом `employments`:

```jsonc theme={null}
// PUT /saas/v2/user/ext/i.ivanov.01011990/update
// Authorization: Bearer <AUTH_TOKEN>
// school-id: <SCHOOL_ID>
// seller-id: <SELLER_ID>

{
    // ----- карточка: обновится как обычно -----
    "email": "ivanov@company.ru",
    "status": "Active",
    "profile": {
        "firstName": "Иван",
        "lastName": "Иванов"
    },

    // ----- назначения: применяются ИДЕМПОТЕНТНО -----
    "extra": {
        "staff": {
            "employments": [
                {
                    // У Иванова УЖЕ есть активное назначение ПОДР-001 + эта должность:
                    // ничего не произойдёт (no-op, дубль не создастся) — безопасно
                    // слать при каждом синке
                    "departmentExtId": "ПОДР-001",
                    "positionExtId": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01"
                },
                {
                    // Такой активной пары департамент+должность у Иванова НЕТ:
                    // создастся НОВОЕ назначение (совместительство) —
                    // старое в ПОДР-001 при этом остаётся активным
                    "departmentExtId": "ПОДР-002",
                    "positionExtId": "0f8e2d10-11aa-4c00-8b2f-777d12003b02",
                    "kind": "InternalSecondary",
                    "startAt": "2026-07-01T00:00:00Z"
                }
            ]
        }
    }
}
```

Правило простое: `extra.employments` умеет только **«убедиться, что назначение есть»**
(нет — нанять, есть — пропустить). Оно НЕ умеет закрывать/менять существующие назначения:

* сотрудника **перевели** → `POST /staff/employment/ext/{extId}/transfer`;
* **повысили** (сменилась должность) → `POST /staff/employment/ext/{extId}/promote`;
* **уволили** → `POST /staff/employment/ext/{extId}/terminate`.

И зеркальное следствие: если существующее назначение **НЕ передать** в `employments` —
с ним НИЧЕГО не произойдёт, оно останется активным. Массив — это не «полный список для
синхронизации»: отсутствие назначения в нём ≠ увольнение с него. Убрать назначение можно
только явным вызовом `terminate`. Поэтому слать можно хоть одно назначение, хоть все —
перечисленные донаймутся/пропустятся, неперечисленные не тронутся.

Если при переводе прислать в `extra` новую пару — получите сотрудника с ДВУМЯ активными
назначениями (старым и новым), а не перевод.

Совместительство — несколько назначений в одном массиве (до 10):

```json theme={null}
"extra": {
    "staff": {
        "employments": [
            { "departmentExtId": "ПОДР-001", "positionExtId": "ДОЛЖ-РУК", "kind": "Main" },
            { "departmentExtId": "ПОДР-002", "positionExtId": "ДОЛЖ-МЕНТОР", "kind": "InternalSecondary" }
        ]
    }
}
```

Вариант без email — вход по domain + password (domain строится из login заменой
недопустимых символов на `_`):

```jsonc theme={null}
// POST /saas/v2/user/create
{
    "extId": "s.sidorov.02021991",              // ← external_id
    "domain": "s.sidorov.02021991",             // ← login "s.sidorov.02021991" (email пустой, передаём как есть)
    "password": "Sid$2024!",                    // ← password (ОБЯЗАТЕЛЕН без email — иначе нечем входить)
    "status": "Active",                         // ← status "Работает"
    "profile": {
        "firstName": "Семён",                   // ← first_name
        "lastName": "Сидоров",                  // ← last_name
        "bdate": "1991-02-02",                  // ← date_of_birth
        "sex": "Men"                            // ← gender "м"
    },
    "extra": {
        "staff": {
            "employments": [
                {
                    "departmentExtId": "ПОДР-001",                              // ← department_external_ids[0]
                    "positionExtId": "e3b0c442-98fc-4b39-96f7-9c2a4d001a01",    // ← job_position_external_id
                    "startAt": "2021-06-15T00:00:00Z"                           // ← date_of_hire_job_poition
                }
            ]
        }
    }
}
```

## Что делать, когда в 1С что-то изменилось

Цикл выше — идемпотентный: его можно гонять хоть каждый час, повторные прогоны безопасны.
Карточные изменения он подхватывает сам, а вот **кадровые** — нет, для них есть отдельные вызовы.
Все они адресуются по `extId` **назначения** (тот самый `extId`, который вы передали в элементе
`employments` при найме — поэтому его важно задавать).

| Изменилось в 1С                      | Что вызвать                                                                                |
| ------------------------------------ | ------------------------------------------------------------------------------------------ |
| ФИО, email, дата рождения, пол       | Ничего отдельно — повторный прогон цикла (`PUT /user/ext/{extId}/update`) обновит карточку |
| Разблокировали («Работает» вернулся) | Тоже цикл — `"status": "Active"` снимет блокировку                                         |
| Перевели в другое подразделение      | `POST /staff/employment/ext/{extId назначения}/transfer`                                   |
| Сменилась должность (повышение)      | `POST /staff/employment/ext/{extId назначения}/promote`                                    |
| Уволили                              | `POST /staff/employment/ext/{extId назначения}/terminate`                                  |
| Приняли обратно                      | Обычный цикл: create/`extra` наймёт заново, статус `Active` вернётся сам                   |
| Добавили совместительство            | Ещё один элемент в `extra.staff.employments` (или `POST /staff/employment/hire`)           |
| Отпуск / больничный                  | `POST /staff/absence/create` — статус `OnLeave` выставится сам                             |
| Переименовали должность              | Ничего — `ОбеспечитьДолжностьПоГуид` актуализирует имя при прогоне                         |
| Переименовали подразделение          | `PUT /staff/department/ext/{код}/update { "name": "..." }`                                 |
| Новое подразделение                  | `POST /staff/department/create` (до синка сотрудников — см. предусловия)                   |

Примеры кадровых вызовов (extId назначения из примера выше — `i.ivanov.01011990:ПОДР-001`):

```http theme={null}
// Перевели Иванова из ПОДР-001 в ПОДР-002 (старое назначение закроется, откроется новое):
POST /saas/v2/staff/employment/ext/i.ivanov.01011990:ПОДР-001/transfer

{ "toDepartmentExtId": "ПОДР-002" }
```

```http theme={null}
// Повысили — сменилась должность (GUID новой должности из 1С):
POST /saas/v2/staff/employment/ext/i.ivanov.01011990:ПОДР-001/promote

{ "toPositionExtId": "0f8e2d10-11aa-4c00-8b2f-777d12003b02" }
```

```http theme={null}
// Уволили (если это последнее активное назначение — user.status сам станет Terminated,
// вход закроется, сессии завершатся):
POST /saas/v2/staff/employment/ext/i.ivanov.01011990:ПОДР-001/terminate

{}
```

```http theme={null}
// Ушёл в отпуск (статус OnLeave выставится сам, вход НЕ блокируется):
POST /saas/v2/staff/absence/create

{
    "employmentExtId": "i.ivanov.01011990:ПОДР-001",
    "type": "Vacation",
    "startAt": "2026-08-01T00:00:00Z",
    "finishAt": "2026-08-15T00:00:00Z"
}
```

Нюанс с extId при transfer/promote: назначение закрывается и открывается новое, но **extId
переезжает на новую запись** — после перевода тот же `i.ivanov.01011990:ПОДР-001` продолжает
указывать на действующее назначение сотрудника. Если в extId зашит код подразделения (как в
примере), он перестанет совпадать с фактическим департаментом — это не ошибка, просто ключ.
Хотите точности — используйте в качестве extId идентификатор записи о назначении из 1С.

## Предусловия

* Подразделения (`ПОДР-001` и т.д.) должны быть созданы заранее:
  `POST /saas/v2/staff/department/create { "name": "Отдел продаж", "extId": "ПОДР-001", "parentExtId": "ПОДР-000" }`.
  Если `departmentExtId` не найден — create вернёт `StaffDepartmentNotFound` и пользователь создан не будет.
* **Кадровые изменения существующего сотрудника** идут через employment-endpoint'ы по extId назначения:
  перевод — `POST /staff/employment/ext/{extId}/transfer { "toDepartmentExtId": "ПОДР-002" }`,
  смена должности — `.../promote { "toPositionExtId": "<GUID>" }`, увольнение — `.../terminate`.
  Новое назначение (в т.ч. совместительство) — `POST /staff/employment/hire`, либо `extra.staff.employments`
  в `user/update`/`user/upsert` — донанимает идемпотентно (повтор той же активной пары
  департамент + должность — no-op); переводы и увольнения через `extra` не выражаются.
* **Увольнение с последнего активного назначения автоматически закрывает аккаунт**: `user.status`
  становится `Terminated`, все сессии завершаются, вход блокируется. Повторный наём (`hire`)
  автоматически возвращает `Active`. Уволить владельца школы или самого себя нельзя
  (`StaffCannotTerminateSchoolOwner` / `StaffCannotTerminateSelf`).
* **Статус `Blocked` (блокировка админом)** ставится через `user/update { "status": "Blocked" }`:
  вход закрыт, сессии завершаются, новые назначения курсов запрещены; данные сохраняются
  и видны в отчётах. `{ "status": "Active" }` снимает блокировку, доступы восстанавливаются.
  Владельца школы заблокировать нельзя.
* **Отсутствия управляют статусом `OnLeave`**: когда у сотрудника начинается действующее absence
  (отпуск/больничный и т.д.), `user.status` автоматически становится `OnLeave`, по завершении/удалении —
  возвращается в `Active`. `OnLeave` — информационный статус: **вход в систему остаётся доступен**.
* `leader_external_ids` мапится не на сотрудника, а на руководителя подразделения — отдельный вызов
  `POST /saas/v2/staff/department-manager/set { "departmentExtId": "ПОДР-001", "employmentExtId": "<extId назначения>", "isPrimary": true }`.
* **`status` из выгрузки мапится на `user.status`**: «Работает» → `"Active"`. «Уволен» напрямую
  статусом не передаётся — увольнение оформляется через `employment/terminate`, и `Terminated`
  выставится автоматически. `OnLeave` тоже вручную не ставится — им управляют absences.
* `role`/`tag_list`/`city`/`email_for_notifications` — соответствия в API нет, поля игнорируются.
* **Сотрудники без email**: передавайте `domain` (латиница/цифры/`_`/точки — точка не по краям и не подряд;
  сервер приведёт к lowercase, уникален в рамках школы — занят → `DomainIsBusy`) и **обязательно
  `password`** — без email и телефона domain+password становится единственным способом входа.
* **Должности — по GUID (`positionExtId`), не по имени**: имя изменяемо и не уникально на стороне 1С
  («добавят мягкий знак — интеграция сломается»). GUID необязателен: без него работает fallback по имени.
  Ограничение: имя должности уникально в рамках школы — две должности с одинаковым именем, но разными GUID
  (разные организации в 1С) создать нельзя (`StaffPositionNameIsNotUniq`) — сведите GUID'ы к одному
  или не передавайте GUID у второй организации.

***

*Обновлено: 2026-07-09 07:40 UTC*
