Timeman 26.100.0: время считается по IANA-зоне сотрудника, у CTimeManEntry и REST другие значения
Ломающее обновление
Удалены или изменены публичные API — прикладной код может перестать работать.
С timeman 26.100.0 учёт рабочего времени берёт зону сотрудника из IANA-идентификатора в b_user.TIME_ZONE и считает смещение UTC на момент каждого события, с переходами на летнее и зимнее время. Форматы полей и ответов остались прежними, поменялись значения: TIME_START, TIME_FINISH и DURATION у легаси CTimeManEntry, время и TZ_OFFSET в ответе timeman.status, время, которое timeman.open и timeman.close берут из TIME, результат User::obtainUtcOffset(). Сильнее всего это ударит по порталам с сотрудниками не из зоны сервера, если свой код пишет в учёт времени, строит по нему отчёты или читает рабочий день по REST.
Что сломается
Проверьте, какая зона записана у сотрудников
Зону определяет TimeHelper::getInstance()->resolveEffectiveTimeZoneId(int $userId), цепочка у него такая:
- Если в main часовые пояса выключены (
CTimeZone::OptionEnabled() && CTimeZone::Enabled()ложно), берётся зона сервера. - Для текущего пользователя берётся
TIME_ZONEиз его параметров, а если он пуст и включено автоопределение, зона из кукиCTimeZone::getTzCookie(). - Для всех остальных читается только сохранённый
b_user.TIME_ZONE. - Если зоны нет, берётся
main.default_time_zone, затем зона сервера.
Найденное значение сверяется со списком timezone_identifiers_list(DateTimeZone::ALL_WITH_BC), поэтому '+03:00', 'GMT+3' и 'MSK' отбрасываются и срабатывает запасной вариант. Результат кешируется на хит. Новая цепочка b_user.TIME_ZONE_OFFSET не читает, грид timeman.worktime.grid тоже перестал выбирать это поле.
Устаревшие getUserUtcOffset() и getUserTimezone() по-прежнему идут через CTimeZone::GetOffset(), и для сотрудника с пустым TIME_ZONE он отдаёт последнее смещение из TIME_ZONE_OFFSET. Внутри модуля их ещё вызывают WorktimeEvent::create(), формы, ShiftPlanService и контроллеры.
Сотрудник с автоопределением и пустым TIME_ZONE в своих запросах получит зону из куки, а в запросах руководителя у него окажется main.default_time_zone или зона сервера. При автоопределении main сам записывает браузерную IANA-зону в b_user.TIME_ZONE через экшен main.timezone.set, так что пустой TIME_ZONE бывает в основном у тех, кто не заходил в веб-версию и работает только через мобильное приложение или REST. Список сотрудников, чья зона не пройдёт проверку, лучше получить до обновления. Конструктор new \DateTimeZone() для этого не подходит, он принимает и '+03:00', и 'MSK', так что сверяем со списком так же, как модуль:
<?php declare(strict_types=1);
use Bitrix\Main\UserTable;
$ianaIds = array_flip(timezone_identifiers_list(\DateTimeZone::ALL_WITH_BC));
$users = UserTable::getList([
'select' => ['ID', 'LOGIN', 'TIME_ZONE'],
'filter' => ['=ACTIVE' => 'Y', '=IS_REAL_USER' => 'Y'],
]);
while ($user = $users->fetch()) {
$zone = (string)$user['TIME_ZONE'];
if (!isset($ianaIds[$zone])) {
// timeman возьмёт main.default_time_zone, а без неё зону сервера
printf("%d %s: '%s'\n", $user['ID'], $user['LOGIN'], $zone);
}
}
Не сравнивайте старые и новые TIME_START в CTimeManEntry
CAllTimeManEntry::CheckFields теперь пишет в TIME_START и TIME_FINISH секунды от полуночи в IANA-зоне сотрудника на момент события, по формуле (ts + getOffsetAt(user, ts)) % 86400. В 26.0.0 формула была серверной, (ts + date('Z')) % 86400. Сотрудник в Екатеринбурге (UTC+5) начинает день в 09:00 по местному, сервер живёт по Москве (UTC+3). До обновления в TIME_START попадало 25200, то есть 07:00, после попадёт 32400. Прямые вызовы CTimeManEntry::Add() и Update() без TIME_* для таких сотрудников начнут сохранять другие числа.
При UPDATE поля DATE_START и DATE_FINISH пересчитываются из новых TIME_* на дату события в зоне сотрудника, серверная разница tz_diff в этом больше не участвует. DURATION теперь равен ts_finish − ts_start − TIME_LEAKS.
Модель WorktimeRecord считает длительность так же, от абсолютных моментов: RECORDED_STOP − RECORDED_START − перерывы с ограничением от 0 до 86400 (calculateElapsedDuration()). В 26.0.0 это была разница TIME_FINISH − TIME_START. В день перехода на летнее или зимнее время старая и новая формулы разойдутся на час.
Конвертации исторических строк в диффе нет. В b_timeman_entries рядом окажутся строки до обновления, где TIME_START посчитан в зоне сервера, и строки после, где он в зоне сотрудника. У сотрудников не из зоны сервера новые значения сдвинутся на разницу зон, и отчёт по TIME_START за такой период покажет скачок в день обновления. Надёжнее строить отчёты от RECORDED_START_TIMESTAMP и RECORDED_STOP_TIMESTAMP со снимками START_OFFSET и STOP_OFFSET.
Учтите, что время в форме правки теперь в зоне сотрудника
WorktimeRecord::updateByForm() трактует введённое время в зоне сотрудника при любых настройках. Флаг useEmployeesTimezone на сохраняемый момент больше не влияет, в коде он описан как «display-only mode now». Изменилось ли время, модель проверяет по «настенным» дате и времени (isWallTimeChanged, допуск 59 секунд). Раньше сравнивались timestamp.
Руководитель в Москве, который в карточке записи сотрудника из Новосибирска с выключенным «по времени сотрудника» вводит окончание 18:00, в 26.0.0 записывал 18:00 по Москве, то есть 22:00 по Новосибирску. Теперь запишется 18:00 по Новосибирску.
StopCustomTimeWorktimeManager строит время окончания в зоне сотрудника (userId) вместо зоны редактора (editedBy).
Смещения в записи тоже считаются на момент события:
START_OFFSETвычисляется вdefineStartTime()черезgetOffsetAt()на момент начала и пересчитывается при каждом переносе начала. Раньше его брали изgetUserUtcOffset()при создании записи и больше не трогали.STOP_OFFSETвычисляется вstopWork()на момент окончания для любого редактора. Раньше только еслиeditedByпуст или совпадает с сотрудником.stopOffsetиrecordedStopTimestampиз формы учитываются только на системном пути (isSystem, автозакрытие), в остальных случаях модель их игнорирует. Комментарий называет эти поля «public loadable form field» и объясняет запрет тем, что через них можно было бы подсунуть старое или поддельное смещение.
Сверьте интеграции с timeman.status, timeman.open и timeman.close
В описании версии вендор пишет, что «формат устаревшего REST (включая TZ_OFFSET) не изменён». Значения полей, включая сам TZ_OFFSET, при этом другие.
У timeman.status:
TIME_STARTиTIME_FINISHстроятся изRECORDED_START_TIMESTAMP/RECORDED_STOP_TIMESTAMPи снимковSTART_OFFSET/STOP_OFFSET;TZ_OFFSETтеперь равенSTART_OFFSET, раньше его считали в момент вызова какgetDayStartOffset() + date('Z');- у приостановленного дня
TIME_FINISHтеперьnull, раньше приходило ISO-время изTIME_FINISH, который модель ставит при паузе; TIME_FINISH_DEFAULTставится на дату начала со смещениемSTART_OFFSET.
timeman.open с параметром TIME пересчитывает момент из ISO-строки в зону сотрудника. Раньше бралось «настенное» время строки, а смещение из ISO игнорировалось. Если интеграция отправляла 2026-10-05T09:00:00+00:00, имея в виду девять утра по Москве, день сотрудника из Москвы открывался в 09:00. Теперь это 12:00 по Москве. Вызов после полудня откроет день в 12:00, а вызов до полудня получит ошибку «Нельзя начинать день со временем, больше текущего» с кодом ALERT_WARNING. Проверка «сегодня» тоже идёт по дате в зоне сотрудника, а дата уходит в openDay() как CUSTOM_DATE.
Передавайте настоящее смещение сотрудника, например (new \DateTimeImmutable('2026-10-05 09:00', new \DateTimeZone('Europe/Moscow')))->format(DATE_ATOM).
timeman.close с TIME больше не подгоняет время через correctTimeOffset() к смещению на момент вызова. Дата сверяется с датой начала в зоне сотрудника, а ночные смены закрывает ветка модели «stop <= start → +1 день».
Если вы наследуетесь от Bitrix\Timeman\Rest, проверьте вызовы его хелперов. Удалены protected static convertTimeToISO(), correctTimeOffset(), formatDateToISO() и formatTimeToISO(), у convertTimeFromISO($isoTime) появился второй параметр $userId, и наследник, переопределивший метод со старой сигнатурой, упадёт с Fatal error «must be compatible». Добавлены convertTimestampToISO(), convertDaySecondsToISO(), formatOffsetSuffix(), getCurrentDateInUserZone() и getRecordedStartDateInUserZone().
Уберите getUserOffset() и не полагайтесь на obtainUtcOffset()
Из TimemanWorktimeGridComponent удалён public static getUserOffset($userData), его вызов закончится фатальной ошибкой. Переключатель «по времени сотрудника» в гриде теперь появляется, когда у пользователей больше одной IANA-зоны. В 26.0.0 хватало больше одного смещения.
Bitrix\Timeman\Model\User\User::obtainUtcOffset() больше не подставляет getUserUtcOffset(). Если смещение не задали через defineUtcOffset(), метод вернёт 0, то есть UTC. Он помечен @internal, вместо него предложен obtainPinnedUtcOffset(), который в этом случае вернёт null. Если коду нужен прежний запасной вариант, подставьте смещение на нужный момент через ??:
<?php declare(strict_types=1);
use Bitrix\Timeman\Helper\TimeHelper;
/** @var \Bitrix\Timeman\Model\User\User $user */
$offset = $user->obtainPinnedUtcOffset()
?? TimeHelper::getInstance()->getOffsetAt((int)$user->getId(), $timestamp);
TimeHelper::getUserToServerOffset() лишился CPHPCache. Раньше значение жило 3153600 секунд при BX_COMP_MANAGED_CACHE (иначе сутки) под тегом USER_NAME_<id>, теперь только в пределах хита.
Обновите код, который читает схему уведомлений
CTimemanNotifySchema::OnGetNotifySchema() возвращает группу ['timeman' => ['NAME' => ..., 'NOTIFY' => [...]]]. Раньше типы уведомлений лежали прямо под ключом timeman. Если вы читаете схему сами, берите типы из ['timeman']['NOTIFY'].
Проверьте расчёты смен и рантайм-данных дня
CTimeManUser::getDayStartOffset()возвращает смещение сотрудника минус смещение сервера, оба на момент начала дня.isDayOpenedToday()сравнивает даты в зоне сотрудника.CTimeMan::GetTimeTS()берёт смещение сервера на момент$ts. ВCUserReportFullвызовdate('Z')тоже заменён наTimeHelper::getInstance()->getServerOffsetAt($timestamp), им жеCTimeManUser::getDayStartOffset()считает смещение сервера.CTimeMan::getRuntimeInfo()заполняетINFO.DATE_STARTиINFO.DATE_FINISHизRECORDED_START_TIMESTAMPиbuildDisplayedStopTimestamp(), в ответе появились ключиDISPLAY_START_TIMESTAMPиDISPLAY_STOP_TIMESTAMP. Pull-событияWorktimeServiceберутdateStartиdateFinishиз абсолютных полей записи.Shift::buildUtcStartByUserId($userId, $shiftDateTime)строит начало смены изWORK_TIME_STARTна календарной дате в IANA-зоне и всегда возвращаетDateTime.buildUtcEndByShiftplan()берётWORK_TIME_ENDна дате смены, у ночной смены на следующей. Прежнюю формулу «начало плюсgetDuration()» убрали.ShiftWithDate::__construct()получил необязательный параметр?int $userIdи считает конец смены черезShift::buildUtcEndByUserId(). БезuserIdконцом считаетсяWORK_TIME_ENDв зоне$dateTimeStart.TimeHelper::getTimestampByUserDate()строит полночь даты в IANA-зоне сотрудника. Раньше это была серверная полночь минус текущее смещение.
Deprecated
В Bitrix\Timeman\Helper\TimeHelper на выпил помечены два метода:
getUserUtcOffset($userId)отдаёт смещение «на сейчас», заменаgetOffsetAt($userId, $timestamp);getUserTimezone($userId)заменяется наgetUserDateTimeZone($userId), который возвращает настоящуюDateTimeZoneс правилами DST.
createTimezoneByOffset() помечен @internal. Синтетическая зона '+HH:MM' нужна только для восстановления истории по снимку смещения, это делает reconstructHistoricalDateTime($timestamp, $snapshotOffset). Перевод оставшихся вызовов getUserUtcOffset() и getUserTimezone() внутри модуля в комментариях назван этапом «P5».
Методы у TimeHelper экземплярные, объект берётся через getInstance(). Обёртка для своего модуля:
<?php declare(strict_types=1);
namespace Vendor\Timesheet\Application\Service;
use Bitrix\Timeman\Helper\TimeHelper;
final class WorkdayClock
{
private readonly TimeHelper $timeHelper;
public function __construct(?TimeHelper $timeHelper = null)
{
$this->timeHelper = $timeHelper ?? TimeHelper::getInstance();
}
public function offsetAt(int $userId, int $timestamp): int
{
// было: $this->timeHelper->getUserUtcOffset($userId), одно смещение на все даты
return $this->timeHelper->getOffsetAt($userId, $timestamp);
}
public function toUserTime(int $userId, int $timestamp): \DateTimeImmutable
{
// было: getUserTimezone($userId), зона без правил DST
return (new \DateTimeImmutable('@' . $timestamp))
->setTimezone($this->timeHelper->getUserDateTimeZone($userId));
}
public function fromWallTime(int $userId, string $date, int $hours, int $minutes): int
{
return $this->timeHelper->buildTimestampFromWallTime($userId, $date, $hours * 3600 + $minutes * 60);
}
}
Для сотрудника с зоной Europe/Berlin offsetAt() вернёт 3600 для 27 марта 2026 года и 7200 для 30 марта, getUserUtcOffset() отдал бы одно число на обе даты. Вызов fromWallTime($userId, '2026-03-29', 2, 30) попадает в час, которого в Берлине в эту ночь нет, и buildTimestampFromWallTime() сдвинет время вперёд. В повторяющийся осенний час результат определяет DateTime в PHP, в комментарии сказано, что это поведение закреплено регрессионным тестом.
Новое
Проверьте таблицу до кнопки «Обсудить отчёт»
Экшен timeman.V2.ReportDiscussion.discuss принимает reportId и вызывает ReportDiscussionService::discuss(). Сервис находит непосредственного руководителя, создаёт или переиспользует групповой чат пары с ENTITY_TYPE = TIMEMAN_REPORT_DISCUSS и ENTITY_ID = <managerId>_<employeeId> и публикует туда отчёт. Связь отчёта с чатом пишется в новую таблицу b_timeman_report_discussion через ReportDiscussionTable::merge() на SqlHelper::prepareMerge(). Рядом появились ReportPeriodPhraseFormatter для фразы с периодом отчёта и FullReportUserService::canUserReadUser().
Таблица есть только в install/db/mysql/install.sql и pgsql/install.sql, миграции для уже установленных порталов в диффе нет. Сервис проверяет её через isTableExists() и без таблицы отдаёт ошибку CHAT_CREATION_ERROR. В комментарии это названо временной страховкой до версионного апдейтера, выпуск которого «deliberately deferred». Пока апдейтер не вышел, на обновлённой коробке кнопка работать не будет. Переустановка модуля с сохранением данных таблицу тоже не создаст, потому что install.sql, как и в 26.0.0, выполняется только при отсутствии b_timeman_entries, а installMigrations() создаёт только b_timeman_entries_intent_state.
Проверить коробку после обновления:
<?php declare(strict_types=1);
use Bitrix\Main\Application;
$exists = Application::getConnection()->isTableExists('b_timeman_report_discussion');
echo $exists
? 'b_timeman_report_discussion на месте'
: 'таблицы нет, «Обсудить отчёт» вернёт CHAT_CREATION_ERROR', PHP_EOL;
Берите руководителя и период из триггеров БП
Три триггера бизнес-процессов отдают новые значения:
FullReportReadyTrigger—MANAGER;FullReportSentTrigger—MANAGER,REPORT_ID,PERIOD_PHRASEиSOURCE_TYPE;StopWorktimeTrigger—WORKDAY_START, DATETIME со смещением начала дня.
FullReportAiGenerator теперь передаёт в триггер «отчёт готов» непосредственного руководителя, первого из managerIds. Правки лежат в install/activities/bitrix/*trigger/, lib/V2/Internal/Integration/Bizproc/ и lib/integration/bizproc/stopworktimetrigger.php.
Учтите 12 часов автозакрытия при check-in
Если включён check-in (Stafftrack\CheckIn::isCheckInStartEnabled()), у свободного графика автозакрытие ставит стоп через 12 часов от начала вместо 9 (buildStopTimestampForCheckInAutoClose). У дня, начатого в 10:00, окончание запишется на 22:00 вместо 19:00. Закроет его агент, который для flextime теперь планируется на начало следующих местных суток после момента «стоп минус секунда», в этом примере на 00:00. До полуночи день остаётся открытым, в 26.0.0 агент срабатывал в момент стопа. AutoCloseWorktimeAgent больше не приравнивает STOP_OFFSET к START_OFFSET.
Собирайте зоны сотрудников одним запросом
Кроме getUserDateTimeZone(), getOffsetAt(), buildTimestampFromWallTime(), reconstructHistoricalDateTime() и getServerOffsetAt() в TimeHelper появились resolveEffectiveTimeZoneId(), formatSignedOffset() и resolveEffectiveTimeZoneIdFromPersisted(). Последний берёт уже загруженный TIME_ZONE и не ходит в b_user, так грид обходится без N+1. Отчёту по отделу он пригодится так же:
<?php declare(strict_types=1);
use Bitrix\Main\UserTable;
use Bitrix\Timeman\Helper\TimeHelper;
/** @param int[] $userIds */
function resolveZones(array $userIds): array
{
$helper = TimeHelper::getInstance();
$zones = [];
$rows = UserTable::getList([
'select' => ['ID', 'TIME_ZONE'],
'filter' => ['@ID' => $userIds],
]);
while ($row = $rows->fetch()) {
$id = (int)$row['ID'];
$zones[$id] = $helper->resolveEffectiveTimeZoneIdFromPersisted($id, (string)$row['TIME_ZONE']);
}
return $zones;
}
Для наследников TimeHelper открыты protected-точки переопределения: isTimeZoneFeatureEnabled(), getPortalDefaultTimeZoneId(), getCurrentUserId(), getCurrentUserRuntimeTimeZoneId() и getPersistedTimeZoneId(). Ещё добавлены Shift::buildUtcEndByUserId(), WorktimeRecord::buildDisplayedStopTimestamp(), User::obtainPinnedUtcOffset(), User::obtainPinnedTimezoneName(), RecordRepository::getClosedStartTimestampsByUserAndRange(), а для обсуждения отчётов FullReportProvider::getByIdWithoutParticipants() и FullReportRepository::getByIdWithoutPayload(). У WorktimeRecord::buildRecordedStopDateTime() появился необязательный параметр $recordedStopTimestamp.
БД
b_timeman_report_discussion:ID,REPORT_ID,CHAT_ID,MANAGER_ID,EMPLOYEE_ID,MAIN_MESSAGE_ID,COMMENT_MESSAGE_ID,CREATED_AT. Индекс UNIQUE по (REPORT_ID,MANAGER_ID) и обычный поCHAT_ID.NULLв*_MESSAGE_IDзначит, что сообщение ещё не доставлено и его можно отправить повторно.b_timeman_entries_intent_stateописана вinstall/migrations/tables.php. Одна строка на сотрудника (USER_IDUNIQUE), в нейFIRST_USE_AT,PERIOD_KEY,TARGET_START_AT,PERIOD_END_AT, счётчикиSHOW_COUNTиDISMISS_COUNT,SUPPRESSED_UNTIL, флагHAS_TARGET_ACTION, отметкиLAST_REGISTERED_ATиLAST_SHOWN_AT.- Установка стала гибридной.
InstallDB(), как и раньше, выполняетinstall.sql, только если нетb_timeman_entries, а после этого теперь всегда вызываетinstallMigrations().UnInstallDB()вызываетuninstallMigrations($dropTables), где$dropTablesистинно приsavedata != 'Y'. Вmigration_config.jsondefaultTableNameравенb_timeman_entries. - Под часовые пояса схему не трогали. Зона берётся из
b_user.TIME_ZONE, вb_timeman_entriesте жеSTART_OFFSET,STOP_OFFSET,RECORDED_START_TIMESTAMPиRECORDED_STOP_TIMESTAMP, поменялось только то, что в них пишется.
Мелочи и находки
- В релизе изменено 245 файлов, +5461/−1287. Около 5100 из этих строк приходится на 123 PHP-файла, ещё 50 файлов занимает замена png на webp.
- Чат пары защищён от подмены.
ENTITY_IDу него предсказуемый, аaddUniqueChat()переиспользует любой чат с таким ключом. В комментарии сказано прямо, что пользователь мог заранее создать такой чат через публичныйim.chat.addи получить туда чужой отчёт. Поэтому при создании найденный чат принимается, только если его автор и точный состав совпадают с парой (isPairChatTrusted()). Чаты, уже записанные вb_timeman_report_discussion, считаются доверенными без этой проверки. Так стоит проверять любой служебный чат с угадываемым ключом. - Черновики отчётов (
ACTIVE=N) не может обсудить никто. Руководитель из оргструктуры без правtm_readилиtm_read_subordinateполучитACCESS_DENIED, иначе, по комментарию, «the manager could brute-force reportId». - В
CTimeMan::buildInfoTimestamps()есть строки «the historical contract restored after 26.100.0» и «DISPLAY_*_TIMESTAMP are synonyms kept for the JS already shipped on them in 26.100.0». Похоже, в эту сборку уже вошло исправление к более ранней сборке 26.100.0. - Докблок
getUserToServerOffset()называет удалённый кеш «~365 days», хотя константа была 3153600 секунд, это 36,5 суток. - Комментарии ссылаются на внутреннюю проектную документацию: ALG-01/02/03, INV-COORD, «ADR invariant», этапы P1–P5, Q-1/Q-2, API-01, LBS #6/#13, quirk #3–#5, DTO-01. Судя по «until they are migrated in P5», переход не закончен.
RecordIntentготовит плашку «welcome-box» с напоминанием начать рабочий день:RecordIntentService,RecordIntentStateRepository, командыRegisterShowCommandиRegisterOutcomeCommand, DTORecordIntentSchedule,RecordIntentProvider::getSchedule(). По константам там режимыdataCollection,smartиshift, до 4 показов (в выходной фиксированного графика 1) и до 4 отказов, повтор раз в час, при сменном графике цель за 20 минут до смены, в режиме сбора данных старт в 05:00 с запасным в 09:00 и история за 7 дней. Ни контроллера, ни JS, которые вызывали бы провайдер или команды, в релизе нет, это заготовка.- Действия БП
fillfullreportactivity,getreportactivity,savedayplanactivityиsavereportactivityубирают ИИ-варианты из списка, еслиBizproc\Public\Service\AiAgent\NodeAvailabilityServiceInterface::isAvailable()вернул false. Реализация в bizproc проверяет наличие модуляaiassistantи доступность ИИ в регионе портала.
Что делать
До обновления найдите в проекте getUserOffset(, obtainUtcOffset(, getUserUtcOffset(, getUserTimezone(, CTimeManEntry::Add(, CTimeManEntry::Update( и чтение OnGetNotifySchema. Интеграторов, которые открывают и закрывают день по REST, предупредите, что смещение в TIME теперь учитывается. После обновления проверьте b_timeman_report_discussion и прогоните список сотрудников с пустой или не-IANA зоной.
Устаревшие и удалённые API в этой версии
| Символ | Статус | Чем заменять |
|---|---|---|
Bitrix\Timeman\Helper\TimeHelper::getUserUtcOffset()
|
Deprecated |
Bitrix\Timeman\Helper\TimeHelper::getOffsetAt()
|
Bitrix\Timeman\Helper\TimeHelper::getUserTimezone()
|
Deprecated |
Bitrix\Timeman\Helper\TimeHelper::getUserDateTimeZone()
|
Bitrix\Timeman\Component\SchedulePlan\TimemanWorktimeGridComponent::getUserOffset()
|
Удалено | — |