Как исправить ошибку проведения в документе "КадровоеПеремещение" и правильно реализовать учет занятых штатных позиций в 1С?

Программист 1С v8.3 (Управляемые формы) Управленческий учет IT и автоматизация бизнеса
← На главную

При разработке кадрового учета в конфигурациях на платформе 1С:Предприятие начинающие разработчики часто сталкиваются со стандартными ошибками при переносе кода (так называемом «копипасте») из одного документа в другой. Типичный пример — копирование логики проведения из документа ПриемНаРаботу в документ КадровоеПеремещение. На первый взгляд задачи документов похожи: оба работают со штатными позициями. Однако логика перемещения сотрудника гораздо сложнее, так как она требует не просто занять новую ставку, но и корректно освободить старую.

В этой статье мы подробно разберем, почему возникает синтаксическая ошибка при переносе кода проведения, почему простое переименование переменных в коде автора темы порождает критические логические ошибки, и как правильно, с точки зрения стандартов разработки 1С, реализовать этот механизм.

---

Анализ синтаксической ошибки: Почему возникла неполадка?

Выясним причину, по которой у автора возникла ошибка «Переменная не определена» при установке параметров запросов в документе КадровоеПеремещение. Посмотрим на исходный фрагмент кода, скопированный из модуля проведения приема на работу:


ЗапросПоРасписанию.УстановитьПараметр("Организация", Организация);
ЗапросПоРасписанию.УстановитьПараметр("Подразделение", Подразделение);
ЗапросПоРасписанию.УстановитьПараметр("Должность", Должность);

В документе ПриемНаРаботу реквизиты шапки назывались просто: Организация, Подразделение и Должность. Обращаясь к ним в модуле объекта напрямую по имени, разработчик получал значения этих реквизитов.

Однако в документе КадровоеПеремещение структура данных иная. Логика перемещения подразумевает перевод из старого подразделения/должности в новые. В реквизитах этого документа отсутствовали поля Подразделение и Должность. Вместо них были созданы реквизиты НовоеПодразделение и НоваяДолжность. Платформа справедливо выдала ошибку компиляции, поскольку переменных с именами Подразделение и Должность в контексте модуля документа КадровоеПеремещение не существовало.

Автор темы решил эту синтаксическую проблему, заменив параметры в запросах на новые реквизиты:


// Решение автора на форуме
ЗапросПоРасписанию.УстановитьПараметр("Подразделение", НовоеПодразделение);
ЗапросПоРасписанию.УстановитьПараметр("Должность", НоваяДолжность);

Синтаксическая ошибка исчезла, документ начал проводиться. Но это решение является иллюзией корректной работы. Оно породило три глубокие логические и архитектурные ошибки, которые мы разберем далее.

---

Логическая ошибка №1: Старая штатная позиция никогда не освобождается

Проанализируем логику движений по регистру накопления ЗанятыеШтатныеПозиции (в коде автора — ЗанятиеШтатныеПозиции), которую написал разработчик в ветке Иначе после проверки лимитов:


// Движение документа - расход
Движения.ЗанятиеШтатныеПозиции.Записывать = Истина;
Движение = Движения.ЗанятиеШтатныеПозиции.Добавить;
Движение.ВидДвижения = ВидДвиженияНакопления.Расход;
Движение.Период = Дата;
Движение.Организация = Организация;
Движение.Подразделение = Подразделение; // Ошибка! Что здесь записано после "исправления"?
Движение.Должность = Должность; // Ошибка!
Движение.КоличествоСтавок = +1;

// Движение документа - приход
Движения.ЗанятиеШтатныеПозиции.Записывать = Истина;
Движение = Движения.ЗанятиеШтатныеПозиции.Добавить;
Движение.ВидДвижения = ВидДвиженияНакопления.Приход;
Движение.Период = Дата;
Движение.Организация = Организация;
Движение.Подразделение = НовоеПодразделение;
Движение.Должность = НоваяДолжность;
Движение.КоличествоСтавок = +1;

После того как автор заменил неопределенные переменные Подразделение и Должность на НовоеПодразделение и НоваяДолжность во всем модуле проведения, в движении Расход также стали фигурировать новые значения.

Что происходит в базе данных при проведении такого перемещения?

  1. Документ делает движение Расход (минус 1 ставка) по Новому подразделению и Новой должности.
  2. В этой же транзакции документ делает движение Приход (плюс 1 ставка) по тем же самым Новому подразделению и Новой должности.
  3. В итоге математический баланс ставок по новой позиции равен нулю (минус 1 и тут же плюс 1). Новая позиция по факту не занимается.
  4. По старой позиции (откуда перевелся сотрудник) не делается вообще никаких движений. Старая ставка остается занятой в регистре навсегда. Данные учета полностью искажаются.

Как решить проблему: Чтобы освободить старое место, программа должна точно знать, откуда переводится сотрудник. Поскольку в документе КадровоеПеремещение нет реквизитов «Старое подразделение» и «Старая должность» (что логично, дабы не заставлять пользователя вводить информацию повторно), их необходимо получить программно из базы данных. Сделать это нужно с помощью запроса к срезу последних регистра сведений СведенияОСотрудниках на момент времени строго до проведения текущего документа.

---

Логическая ошибка №2: Неправильная работа со срезом последних

Рассмотрим запрос, который автор использует для проверки, является ли физлицо сотрудником (ЗапросПоТрудоустройству):


ВЫБРАТЬ
    СведенияОСотрудниках.Сотрудник КАК Сотрудник
ИЗ
    РегистрСведений.SведенияОСотрудниках.СрезПоследних КАК СведенияОСотрудниках
ГДЕ
    СведенияОСотрудниках.Сотрудник = &Сотрудник
    И НЕ СведенияОСотрудниках.ПризнакУвольнения
    И СведенияОСотрудниках.Регистратор <> &Регистратор

Использование конструкции СведенияОСотрудниках.Регистратор <> &Регистратор в секции ГДЕ для виртуальной таблицы СрезПоследних — это классическая и очень грубая ошибка в программировании 1С. Выясним, почему такой код будет работать неверно:

  1. Суть работы виртуальной таблицы: Платформа 1С сначала вычисляет срез последних на указанную дату (или на текущий момент, если период не задан) в разрезе измерений (в данном случае — по сотруднику). Если документ перепроводится, то в базе данных уже существуют его старые движения по этому регистру, и именно они могут оказаться самыми свежими (последними). Платформа берет эту строку.
  2. Суть фильтра ГДЕ: Только после того, как виртуальная таблица вернула одну единственную (последнюю) запись, СУБД начинает применять условия из секции ГДЕ. Она видит условие Регистратор <> &Регистратор, понимает, что регистратором этой последней записи является наш текущий документ, и... просто отфильтровывает (удаляет) эту строку из результата!
  3. Результат: Запрос возвращает пустой результат. Программа считает, что сотрудник не трудоустроен, и взводит флаг Отказ = Истина. Провести документ повторно становится невозможно.

Как решить проблему: Для правильного исключения движений текущего документа из расчета среза последних необходимо использовать объект Граница с видом границы Исключая. Эту границу нужно передавать в качестве первого параметра (Период) виртуальной таблицы среза последних. Фильтр по регистратору в ГДЕ использовать категорически нельзя.

---

Логическая ошибка №3: Нарушение методики проведения и транзакционных блокировок

В коде автора реализована так называемая «старая» методика проведения. Сначала выполняются запросы к остаткам (проверяется количество свободных ставок), затем в коде принимается решение о возможности проведения, и в самом конце записываются движения.

Такой подход имеет колоссальные уязвимости в реальной многопользовательской системе:

Как решить проблему: Рекомендуется перейти на новую методику проведения (проведение от обратного):

  1. Сначала безусловно записываем новые движения документа в регистр сведений СведенияОСотрудниках и регистр накопления ЗанятыеШтатныеПозиции. При этом старые движения документа очищаются автоматически платформой 1С.
  2. Затем накладываем блокировку на данные.
  3. Выполняем один контролирующий запрос, который проверяет остатки ставок в регистре с учетом только что записанных нами движений.
  4. Если запрос обнаруживает, что остаток по какой-либо позиции стал отрицательным (или превысил лимит из штатного расписания), мы выводим сообщение об ошибке, устанавливаем Отказ = Истина, и транзакция автоматически откатывается, отменяя все записи в базе данных.

---

Оптимизация производительности: Пакетные запросы

Автор выполняет три последовательных запроса к базе данных: по штатному расписанию, по занятым позициям и по трудоустройству сотрудника. Каждый вызов метода Выполнить() — это отдельное сетевое обращение к СУБД. Это крайне неэффективно.

Разберем, как объединить все три запроса в один производительный пакетный запрос. Мы получим все данные за одно обращение к серверу базы данных, используя менеджер временных таблиц.

---

Пошаговое решение и правильный код проведения

Разберем правильную и надежную реализацию проведения документа КадровоеПеремещение. Мы применим новую методику проведения, пакетные запросы, объект Граница для среза последних и транзакционные блокировки.

Ниже представлен готовый, оптимизированный код для модуля документа КадровоеПеремещение:


Процедура ОбработкаПроведения(Отказ, Режим)
    
    // Очищаем старые движения (необходимо для новой методики проведения)
    Движения.СведенияОСотрудниках.Записывать = Истина;
    Движения.СведенияОСотрудниках.Очистить();
    
    Движения.ЗанятиеШтатныеПозиции.Записывать = Истина;
    Движения.ЗанятиеШтатныеПозиции.Очистить();
    
    Движения.ДанныеОбОкладах.Записывать = Истина;
    Движения.ДанныеОбОкладах.Очистить();
    
    // -----------------------------------------------------------------------------
    // ЭТАП 1: Получение старого места работы сотрудника до проведения документа
    // Исключаем движения текущего документа из среза последних с помощью Границы
    ГраницаКонтроля = Новый Граница(МоментВремени(), ВидГраницы.Исключая);
    
    ЗапросПредыдущихДанных = Новый Запрос;
    ЗапросПредыдущихДанных.Текст = 
        "ВЫБРАТЬ
        |	СведенияОСотрудникахСрезПоследних.Подразделение КАК СтароеПодразделение,
        |	СведенияОСотрудникахСрезПоследних.Должность КАК СтараяДолжность,
        |	СведенияОСотрудникахСрезПоследних.ПризнакУвольнения КАК БылУволен
        |ИЗ
        |	РегистрСведений.СведенияОСотрудниках.СрезПоследних(&Период, Сотрудник = &Сотрудник) КАК СведенияОСотрудникахСрезПоследних";
        
    ЗапросПредыдущихДанных.УстановитьПараметр("Период", ГраницаКонтроля);
    ЗапросПредыдущихДанных.УстановитьПараметр("Сотрудник", Сотрудник);
    
    ВыборкаПредыдущих = ЗапросПредыдущихДанных.Выполнить().Выбрать();
    
    СтароеПодразделение = Справочники.Подразделения.ПустаяСсылка();
    СтараяДолжность = Справочники.Должности.ПустаяСсылка();
    СотрудникАктивен = Ложь;
    
    Если ВыборкаПредыдущих.Следующий() Тогда
        Если НЕ ВыборкаПредыдущих.БылУволен Тогда
            СотрудникАктивен = Истина;
            СтароеПодразделение = ВыборкаПредыдущих.СтароеПодразделение;
            СтараяДолжность = ВыборкаПредыдущих.СтараяДолжность;
        КонецЕсли;
    КонецЕсли;
    
    // Проверяем, действительно ли физлицо является активным сотрудником
    Если НЕ СотрудникАктивен Тогда
        Отказ = Истина;
        Сообщить("Невозможно перевести лицо, не являющееся активным сотрудником компании!");
        Возврат;
    КонецЕсли;
    
    // -----------------------------------------------------------------------------
    // ЭТАП 2: Формирование новых движений (записываем их "авансом")
    
    // 1. Движение по сведениям о сотрудниках
    ДвижениеСотрудник = Движения.СведенияОСотрудниках.Добавить();
    ДвижениеСотрудник.Период = Дата;
    ДвижениеСотрудник.Организация = Организация;
    ДвижениеСотрудник.Сотрудник = Сотрудник;
    ДвижениеСотрудник.Подразделение = НовоеПодразделение;
    ДвижениеСотрудник.Должность = НоваяДолжность;
    ДвижениеСотрудник.ПризнакУвольнения = Ложь;
    
    // 2. Движение по окладам
    ДвижениеОклад = Движения.ДанныеОбОкладах.Добавить();
    ДвижениеОклад.Период = Дата;
    ДвижениеОклад.Организация = Организация;
    ДвижениеОклад.Сотрудник = Сотрудник;
    ДвижениеОклад.Оклад = НовыйОклад;
    
    // 3. Освобождаем старую штатную позицию (Расход)
    ДвижениеРасход = Движения.ЗанятиеШтатныеПозиции.Добавить();
    ДвижениеРасход.ВидДвижения = ВидДвиженияНакопления.Расход;
    ДвижениеРасход.Период = Дата;
    ДвижениеРасход.Организация = Организация;
    ДвижениеРасход.Подразделение = СтароеПодразделение;
    ДвижениеРасход.Должность = СтараяДолжность;
    ДвижениеРасход.КоличествоСтавок = 1;
    
    // 4. Занимаем новую штатную позицию (Приход)
    ДвижениеПриход = Движения.ЗанятиеШтатныеПозиции.Добавить();
    ДвижениеПриход.ВидДвижения = ВидДвиженияНакопления.Приход;
    ДвижениеПриход.Период = Дата;
    ДвижениеПриход.Организация = Организация;
    ДвижениеПриход.Подразделение = НовоеПодразделение;
    ДвижениеПриход.Должность = НоваяДолжность;
    ДвижениеПриход.КоличествоСтавок = 1;
    
    // Записываем движения в базу данных, чтобы контролировать остатки по новой методике
    Движения.СведенияОСотрудниках.Записать();
    Движения.ЗанятиеШтатныеПозиции.Записать();
    Движения.ДанныеОбОкладах.Записать();
    
    // -----------------------------------------------------------------------------
    // ЭТАП 3: Блокировка данных перед чтением остатков для предотвращения Race Condition
    Блокировка = Новый БлокировкаДанных;
    
    ЭлементБлокировки = Блокировка.Добавить("РегистрНакопления.ЗанятиеШтатныеПозиции");
    ЭлементБлокировки.Режим = РежимБлокировкиДанных.Исключительный;
    ЭлементБлокировки.УстановитьПараметр("Организация", Организация);
    ЭлементБлокировки.УстановитьПараметр("Подразделение", НовоеПодразделение);
    ЭлементБлокировки.УстановитьПараметр("Должность", НоваяДолжность);
    
    Блокировка.Заблокировать();
    
    // -----------------------------------------------------------------------------
    // ЭТАП 4: Контролирующий пакетный запрос (Новая методика)
    // Проверяем, не превысили ли мы лимит по новой штатной позиции
    
    ПакетныйЗапрос = Новый Запрос;
    ПакетныйЗапрос.Текст = 
        "ВЫБРАТЬ
        |	ШтатноеРасписание.КоличествоСтавок КАК ВсегоСтавокПоШтату
        |ПОМЕСТИТЬ ВТ_Штат
        |ИЗ
        |	РегистрСведений.ШтатноеРасписание КАК ШтатноеРасписание
        |ГДЕ
        |	ШтатноеРасписание.Организация = &Организация
        |	И ШтатноеРасписание.Подразделение = &Подразделение
        |	И ШтатноеРасписание.Должность = &Должность
        |;
        |
        |////////////////////////////////////////////////////////////////////////////////
        |ВЫБРАТЬ
        |	ЗанятыеОстатки.КоличествоСтавокОстаток КАК ЗанятоСтавок
        |ПОМЕСТИТЬ ВТ_Занято
        |ИЗ
        |	РегистрНакопления.ЗанятиеШтатныеПозиции.Остатки(
        |			,
        |			Организация = &Организация
        |				И Подразделение = &Подразделение
        |				И Должность = &Должность) КАК ЗанятыеОстатки
        |;
        |
        |////////////////////////////////////////////////////////////////////////////////
        |ВЫБРАТЬ
        |	ЕСТЬNULL(ВТ_Штат.ВсегоСтавокПоШтату, 0) КАК ВсегоСтавок,
        |	ЕСТЬNULL(ВТ_Занято.ЗанятоСтавок, 0) КАК ЗанятоСтавок
        |ИЗ
        |	ВТ_Штат КАК ВТ_Штат
        |		ПОЛНОЕ СОЕДИНЕНИЕ ВТ_Занято КАК ВТ_Занято
        |		ПО (ИСТИНА)";
        
    ПакетныйЗапрос.УстановитьПараметр("Организация", Организация);
    ПакетныйЗапрос.УстановитьПараметр("Подразделение", НовоеПодразделение);
    ПакетныйЗапрос.УстановитьПараметр("Должность", НоваяДолжность);
    
    РезультатЗапроса = ПакетныйЗапрос.Выполнить();
    ВыборкаКонтроль = РезультатЗапроса.Выбрать();
    
    Если ВыборкаКонтроль.Следующий() Тогда
        ДоступноСтавок = ВыборкаКонтроль.ВсегоСтавок;
        ЗанятоСтавок = ВыборкаКонтроль.ЗанятоСтавок;
        
        Если ЗанятоСтавок > ДоступноСтавок Тогда
            Отказ = Истина;
            Сообщить("Недостаточно свободных ставок по новой должности! Штат переполнен.");
            Сообщить("Всего по штатному расписанию: " + ДоступноСтавок + ", требуется занять: " + ЗанятоСтавок);
        КонецЕсли;
    Иначе
        // Если штатного расписания для этой позиции вообще нет
        Отказ = Истина;
        Сообщить("Выбранная штатная позиция отсутствует в штатном расписании организации!");
    КонецЕсли;
    
    // Если Отказ = Истина, транзакция проведения завершится ошибкой, и все движения откатятся автоматически.
КонецПроцедуры

---

Резюме и выводы

Проанализировав ситуацию, мы видим, что простая синтаксическая ошибка («Переменная не определена») скрывала под собой пласт серьезных архитектурных упущений.

Важные правила, которые необходимо запомнить каждому разработчику 1С:

  1. Никогда не копируйте код бездумно. Документы ПриемНаРаботу и КадровоеПеремещение имеют принципиально разную бизнес-логику работы со ставками.
  2. Для получения среза последних в модулях проведения всегда передавайте объект Граница(МоментВремени(), ВидГраницы.Исключая) в качестве периода. Фильтрация по регистратору в секции ГДЕ ломает проведение при повторных транзакциях.
  3. Используйте новую методику проведения. Сначала пишите движения, а затем контролируйте остатки пакетным запросом. Это избавит вас от некорректных блокировок и ложных отказов при перепроведении документов.
  4. В многопользовательской среде обязательно применяйте объект БлокировкаДанных перед получением остатков для предотвращения конкурентного доступа к ресурсам.
← На главную