Автоматизация учета рабочего времени сотрудников в системе 1С — это классическая задача, с которой рано или поздно сталкивается любой разработчик. На первый взгляд решение кажется очевидным: достаточно выгрузить события входа и выхода из журнала регистрации и посчитать разницу во времени. Однако на практике разработчики сталкиваются с целым рядом трудностей: аварийными «вылетами» программы, сеансами, оставленными на ночь, открытием нескольких окон под одной учетной записью и огромной нагрузкой на сервер при чтении тяжелых логов.
В этой статье мы подробно и по шагам разберем, как решить задачу точного учета времени работы пользователей. Мы детально изучим несколько подходов — от простого анализа журнала регистрации до создания профессиональной архитектуры на регистрах сведений с использованием регламентных заданий. Это поможет вам выбрать наиболее оптимальный вариант для вашей конфигурации, будь то классическая УПП 8.2 на обычных формах или современная система на базе БСП.
Давайте вместе разберемся со всеми тонкостями этой задачи и напишем отказоустойчивый код.
---
Самый быстрый способ получить данные без изменения метаданных конфигурации — использование встроенного журнала регистрации. Для этого в платформе 1С предусмотрен метод ВыгрузитьЖурналРегистрации(). Чтобы не перегружать память лишними записями, мы сразу применим жесткий фильтр по типам событий.
Давайте посмотрим, как программно отобрать только события входа и выхода пользователей. Системные имена этих событий в 1С выглядят следующим образом:
"_$Session$_.Start""_$Session$_.Finish"Проанализируем пример кода для первоначальной выгрузки данных во временную таблицу значений:
// Определяем параметры отбора для журнала регистрации
Фильтр = Новый Структура;
Фильтр.Вставить("ДатаНачала", НачалоПериода);
Фильтр.Вставить("ДатаОкончания", КонецПериода);
МассивСобытий = Новый Массив;
МассивСобытий.Добавить("_$Session$_.Start"); // Старт сеанса
МассивСобытий.Добавить("_$Session$_.Finish"); // Конец сеанса
Фильтр.Вставить("Событие", МассивСобытий);
// Создаем таблицу для выгрузки результатов
ТаблицаЖР = Новый ТаблицаЗначений;
ВыгрузитьЖурналРегистрации(ТаблицаЖР, Фильтр);
После выполнения этого кода мы получим таблицу ТаблицаЖР со служебными колонками, среди которых ключевыми для нас являются Сеанс (уникальный номер сеанса), Пользователь, Дата и Событие.
Если мы просто сгруппируем данные по пользователю за сутки и найдем разность между самым ранним входом и самым поздним выходом, наш расчет будет в корне неверным. Рассмотрим типичную ситуацию: сотрудник пришел на работу в 08:00, поработал до 12:00, полностью вышел из программы и ушел по делам. В 17:00 он зашел на 5 минут проверить почту и снова вышел. Простой расчет по крайним точкам покажет, что сотрудник отработал более 9 часов, хотя реальное время работы составило чуть более 4 часов.
Поэтому связывать события входа и выхода необходимо строго по уникальному номеру сеанса (колонка Сеанс). Номер сеанса уникален в рамках текущего запуска сервера приложений 1С.
Если компьютер пользователя перезагрузился, пропал интернет или процесс 1С был аварийно завершен через диспетчер задач, событие выхода "_$Session$_.Finish" записано не будет. В результате у нас останется «открытый» сеанс без времени окончания.
Чтобы решить эту проблему с высокой точностью, применим эвристический анализ. Если для конкретного сеанса есть старт, но нет финиша, мы выполним точечный микрозапрос к журналу регистрации по этому сеансу и найдем самое последнее событие любого типа (изменение документа, проведение, открытие формы, формирование отчета), которое совершил данный пользователь. Это время и будет считаться фактическим окончанием работы.
Разберем готовый алгоритм сопоставления сеансов и обработки «вылетов» по шагам:
// Создаем результирующую таблицу для отчета
ТаблицаРезультат = Новый ТаблицаЗначений;
ТаблицаРезультат.Колонки.Добавить("Пользователь", Новый ОписаниеТипов("СправочникСсылка.Пользователи"));
ТаблицаРезультат.Колонки.Добавить("ДатаРаботы", Новый ОписаниеТипов("Дата"));
ТаблицаРезультат.Колонки.Добавить("ВремяНачала", Новый ОписаниеТипов("Дата"));
ТаблицаРезультат.Колонки.Добавить("ВремяОкончания", Новый ОписаниеТипов("Дата"));
ТаблицаРезультат.Колонки.Добавить("ДлительностьСекунд", Новый ОписаниеТипов("Число"));
// Группируем логи по уникальному номеру сеанса в соответствие структур
СоответствиеСеансов = Новый Соответствие;
Для Каждого СтрокаЛога Из ТаблицаЖР Цикл
НомерСеанса = СтрокаЛога.Сеанс;
ДанныеСеанса = СоответствиеСеансов.Получить(НомерСеанса);
Если ДанныеСеанса = Неопределено Тогда
ДанныеСеанса = Новый Структура;
ДанныеСеанса.Вставить("Пользователь", СтрокаЛога.Пользователь);
ДанныеСеанса.Вставить("ВремяНачала", Неопределено);
ДанныеСеанса.Вставить("ВремяОкончания", Неопределено);
СоответствиеСеансов.Вставить(НомерСеанса, ДанныеСеанса);
КонецЕсли;
Если СтрокаЛога.Событие = "_$Session$_.Start" Тогда
ДанныеСеанса.ВремяНачала = СтрокаЛога.Дата;
ИначеЕсли СтрокаЛога.Событие = "_$Session$_.Finish" Тогда
ДанныеСеанса.ВремяОкончания = СтрокаЛога.Дата;
КонецЕсли;
КонецЦикла;
// Теперь обрабатываем незакрытые сеансы (вылеты) и заполняем результат
Для Каждого КлючИЗначение Из СоответствиеСеансов Цикл
СеансID = КлючИЗначение.Ключ;
ДанныеСеанса = КлючИЗначение.Значение;
// Пропускаем сеансы, по которым нет информации о старте в текущем периоде логов
Если ДанныеСеанса.ВремяНачала = Неопределено Тогда
Продолжить;
КонецЕсли;
// Если сеанс "завис" (нет события выхода), ищем его последнюю активность в ЖР
Если ДанныеСеанса.ВремяОкончания = Неопределено Тогда
ФильтрАктивности = Новый Структура;
ФильтрАктивности.Вставить("Сеанс", СеансID);
ФильтрАктивности.Вставить("ДатаНачала", ДанныеСеанса.ВремяНачала);
ФильтрАктивности.Вставить("ДатаОкончания", КонецПериода);
ТаблицаАктивности = Новый ТаблицаЗначений;
// Извлекаем только 1 последнюю запись активности
ВыгрузитьЖурналРегистрации(ТаблицаАктивности, ФильтрАктивности, "Дата", , 1);
Если ТаблицаАктивности.Количество() > 0 Тогда
ДанныеСеанса.ВремяОкончания = ТаблицаАктивности[0].Дата;
Иначе
// Если совсем никаких действий не было, фиксируем закрытие по времени начала
ДанныеСеанса.ВремяОкончания = ДанныеСеанса.ВремяНачала;
КонецЕсли;
КонецЕсли;
// Заполняем результирующую таблицу
НоваяСтрока = ТаблицаРезультат.Добавить();
НоваяСтрока.Пользователь = ДанныеСеанса.Пользователь;
НоваяСтрока.ДатаРаботы = НачалоДня(ДанныеСеанса.ВремяНачала);
НоваяСтрока.ВремяНачала = ДанныеСеанса.ВремяНачала;
НоваяСтрока.ВремяОкончания = ДанныеСеанса.ВремяОкончания;
НоваяСтрока.ДлительностьСекунд = ДанныеСеанса.ВремяОкончания - ДанныеСеанса.ВремяНачала;
КонецЦикла;
Если сотрудник работает в ночную смену и зашел в систему в 22:00, а вышел в 06:00 следующего дня, при формировании отчетов по дням (например, для табеля учета рабочего времени) этот интервал нужно корректно разбить на два дня.
Для реализации этой логики при заполнении таблицы результатов мы проверяем, совпадает ли день начала и день окончания сеанса. Если дни разные, программно создаем две строки в отчете: первая строка зафиксирует работу от ВремяНачала до 23:59:59 первого дня, а вторая — от 00:00:00 до ВремяОкончания следующего дня.
---
Несмотря на то, что первый вариант отлично работает без изменения структуры конфигурации, у него есть огромный минус — низкая производительность. В крупных базах данных (особенно в исторических конфигурациях класса УПП 1.3) журнал регистрации может весить десятки гигабайт. Попытка прочитать его за месяц «повесит» сервер и вызовет недовольство пользователей.
Профессиональный подход заключается в том, чтобы фиксировать сеансы самостоятельно в режиме реального времени с помощью метаданных 1С. Разберем по шагам, как спроектировать такую подсистему.
Создадим в конфигураторе новый непериодический регистр сведений ИсторияСеансовПользователей со следующими реквизитами:
Пользователь (Тип: СправочникСсылка.Пользователи)НомерСеанса (Тип: Число, длина 10, без знака)ВремяНачала (Тип: Дата)ВремяОкончания (Тип: Дата)Активен (Тип: Булево)ИмяКомпьютера (Тип: Строка, длина 100)В модуле обычного приложения (или управляемого приложения в зависимости от режима запуска) пропишем фиксацию входа и корректного выхода пользователя.
В процедуре ПриНачалеРаботыСистемы() добавляем следующий код регистрации входа:
Процедура ПриНачалеРаботыСистемы()
// Записываем информацию о старте сеанса
МенеджерЗаписи = РегистрыСведений.ИсторияСеансовПользователей.СоздатьМенеджерЗаписи();
МенеджерЗаписи.Пользователь = ПараметрыСеанса.ТекущийПользователь;
МенеджерЗаписи.НомерСеанса = НомерСеансаИнформационнойБазы();
МенеджерЗаписи.ВремяНачала = ТекущаяДата();
МенеджерЗаписи.Активен = Истина;
МенеджерЗаписи.ИмяКомпьютера = ИмяКомпьютера();
Попытка
МенеджерЗаписи.Записать(Истина);
Исключение
ЗаписьЖурналаРегистрации("РегистрацияСеанса.Ошибка", УровеньЖурналаРегистрации.Ошибка, , , ПодробноеПредставлениеОшибки(ИнформацияОбОшибке()));
КонецПопытки;
КонецПроцедуры
В процедуре ПередЗавершениемРаботыСистемы() прописываем закрытие сеанса при штатном выходе:
Процедура ПередЗавершениемРаботыСистемы()
// Фиксируем штатное время выхода
МенеджерЗаписи = РегистрыСведений.ИсторияСеансовПользователей.СоздатьМенеджерЗаписи();
МенеджерЗаписи.Пользователь = ПараметрыСеанса.ТекущийПользователь;
МенеджерЗаписи.НомерСеанса = НомерСеансаИнформационнойБазы();
МенеджерЗаписи.Прочитать();
Если МенеджерЗаписи.Выбран() Тогда
МенеджерЗаписи.ВремяОкончания = ТекущаяДата();
МенеджерЗаписи.Активен = Ложь;
Попытка
МенеджерЗаписи.Записать(Истина);
Исключение
ЗаписьЖурналаРегистрации("РегистрацияСеанса.Ошибка", УровеньЖурналаРегистрации.Ошибка, , , ПодробноеПредставлениеОшибки(ИнформацияОбОшибке()));
КонецПопытки;
КонецЕсли;
КонецПроцедуры
Как мы уже выяснили, в случае сбоев и зависаний процедура ПередЗавершениемРаботыСистемы() не сработает. Чтобы решить эту проблему элегантно и без обращения к журналу регистрации, мы напишем регламентное задание, которое будет запускаться на сервере каждые 10-15 минут.
Идея алгоритма проста: мы получаем список реально активных на сервере сеансов с помощью метода платформы ПолучитьСеансыИнформационнойБазы(). Затем мы сопоставляем его с записями в нашем регистре, где установлен флаг Активен = Истина. Если запись в нашей базе считается активной, но в списке реальных сеансов сервера этого номера больше нет, значит произошел «вылет». Нам остается лишь закрыть запись в регистре, установив в качестве времени окончания время последней проверки.
Рассмотрим код серверного общего модуля для этого регламентного задания:
Процедура ЗакрытьАварийноЗавершенныеСеансы() Экспорт
// Получаем массив реально активных сеансов на сервере
МассивРеальныхСеансов = ПолучитьСеансыИнформационнойБазы();
СоответствиеСеансов = Новый Соответствие;
Для Каждого ТекущийСеанс Из МассивРеальныхСеансов Цикл
СоответствиеСеансов.Вставить(ТекущийСеанс.НомерСеанса, Истина);
КонецЦикла;
// Запрашиваем из базы данных сеансы, которые у нас числятся активными
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| ИсторияСеансов.Пользователь КАК Пользователь,
| ИсторияСеансов.НомерСеанса КАК НомерСеанса
|ИЗ
| РегистрСведений.ИсторияСеансовПользователей КАК ИсторияСеансов
|ГДЕ
| ИсторияСеансов.Активен = ИСТИНА";
РезультатЗапроса = Запрос.Выполнить();
Выборка = РезультатЗапроса.Выбрать();
Пока Выборка.Следующий() Цикл
// Если открытого сеанса нет среди реальных сеансов сервера - закрываем его
Если СоответствиеСеансов.Получить(Выборка.НомерСеанса) = Неопределено Тогда
НаборЗаписей = РегистрыСведений.ИсторияСеансовПользователей.СоздатьНаборЗаписей();
НаборЗаписей.Отбор.Пользователь.Установить(Выборка.Пользователь);
НаборЗаписей.Отбор.НомерСеанса.Установить(Выборка.НомерСеанса);
НаборЗаписей.Прочитать();
Если НаборЗаписей.Количество() > 0 Тогда
СтрокаЗаписи = НаборЗаписей[0];
СтрокаЗаписи.ВремяОкончания = ТекущаяДата(); // Время обнаружения падения сеанса
СтрокаЗаписи.Активен = Ложь;
Попытка
НаборЗаписей.Записать();
Исключение
ЗаписьЖурналаРегистрации("РегистрацияСеанса.ОшибкаРегламента",
УровеньЖурналаРегистрации.Предупреждение, , ,
"Не удалось закрыть сеанс №" + Выборка.НомерСеанса);
КонецПопытки;
КонецЕсли;
КонецЕсли;
КонецЦикла;
КонецПроцедуры
Спроектировав такую архитектуру, вы получите механизм, который позволит строить любые отчеты по времени работы за пару секунд простым запросом к регистру сведений ИсторияСеансовПользователей. Нагрузка на сервер при этом будет стремиться к нулю.
---
Если ваша конфигурация разработана на управляемых формах или содержит в себе интегрированную Библиотеку Стандартных Подсистем (БСП), вам не нужно изобретать велосипед. В составе БСП уже есть готовый отчет «Анализ журнала регистрации» (в некоторых редакциях он может называться «Активность пользователя»).
В этом отчете уже реализованы механизмы парсинга логов, группировки сеансов и графического отображения интервалов активности. Чтобы проверить наличие этого функционала в вашей конфигурации:
Если у вас устаревшая база (например, УПП 1.3 на платформе 8.2), вы можете выгрузить данный отчет в качестве внешней обработки из любой актуальной демо-базы (например, «Бухгалтерия предприятия» или «Зарплата и управление персоналом») и адаптировать его под свои нужды, так как код отчетов БСП открыт для редактирования.
---
Посчитать «чистое» время нахождения в системе — это лишь половина дела. Часто возникает ситуация, когда пользователь запустил 1С в 9:00, ушел на обед, совещание или вообще ушел домой, оставив компьютер включенным, а программа продолжала работать до вечера. В результате отчет покажет 12 часов непрерывного труда, хотя реальной работы было гораздо меньше.
Чтобы бороться с подобными простоями, мы рекомендуем настроить два важнейших механизма:
ПодключитьОбработчикОжидания()), который будет проверять активность пользователя. Если в течение определенного времени не происходило нажатий клавиш или движений мыши, программа выведет предупреждающее окно с обратным отсчетом и, при отсутствии реакции сотрудника, корректно закроет клиентское приложение 1С.Сочетание этих подходов позволит вам получить максимально достоверную информацию об активности сотрудников компании и оптимизировать использование лицензий 1С на предприятии.