Humanresources 26.500.0: виртуальные пользователи пропали из выборок и счётчиков оргструктуры
Ломающее обновление
Удалены или изменены публичные API — прикладной код может перестать работать.
В humanresources 26.500.0 (коробочный Битрикс24) фильтры, репозитории и счётчики оргструктуры по умолчанию перестали видеть ботов, пользователей приложений и остальных виртуальных пользователей. В том же релизе поменяли местами перепутанные ONLY_ACTIVE и ONLY_GLOBAL_ACTIVE, RolePermissionService начал бросать \DomainException, а установщик переехал на install/migrations. Проверьте свой код, если он собирает участников отделов, считает сотрудников или сохраняет роли доступа, а авторам приложений 1С для HCM Link стоит посмотреть на новый поток расчётных листков и отпусков по ПИН-коду.
Что сломается
Верните виртуальных пользователей туда, где они нужны
Реальным модуль теперь считает пользователя с IS_REAL_USER = 'Y' в b_user, то есть того, чей EXTERNAL_AUTH_ID не входит в UserTable::getExternalUserTypes(). Пользователи с типами bot, email, replica, imconnector и другими внешними из выборок по умолчанию выпадают. Условие собрано подзапросом в новом Internals\Repository\Query\RealUserFilter.
Где это проявится:
Builder\Structure\Filter\NodeMemberFilterполучил последний параметрbool $withVirtualUsers = false. ПриentityType = USERфильтр добавляет условие «ENTITY_IDсреди реальных пользователей». Код, который собирает участников черезNodeMemberDataBuilderс этим фильтром, после обновления перестанет видеть ботов и пользователей приложений.- У
Repository\NodeMemberRepository(Container::getNodeMemberRepository()) методfindAllByNodeId()раньше отдавал участников любого типа, теперь толькоENTITY_TYPE = USERи только реальных. Тот же фильтр встал вfindAllByNodeIdAndRoleIdList(),findAllByRoleIdAndNodeCollection(),findAllByRoleIdAndStructureId(),findAllByRoleIdAndNodeId(),countAllByStructureAndGroupByNode()и вfindAllByNodeIdAndEntityType()для USER. Сигнатуры прежние, флага у этих методов нет. Public\Service\Department\UserService::getTotalEmployeeCount()иUtil\NodeMemberCounterHelper::countByNodeId()получилиbool $withVirtualUsers = falseи по умолчанию возвращают меньшие числа, чем раньше.countByNodeId()без флага к тому же считает толькоENTITY_TYPE = USER. В ключи кеша обоих методов добавлен суффикс_with_virtual_Yили_with_virtual_N.Public\Service\NodeMemberService::findAllByRoleIdAndNodeId()получилwithVirtualUsers = falseи виртуальных скрывает. УfindAllByEntityIds()значение по умолчаниюtrue, по докблоку этот метод обслуживает контуры прав и целостности. Во внутреннемInternals\Repository\Structure\NodeMemberRepositoryfalseпо умолчанию стоит уcountUniqueUsersByNodeIdWithSubNodes(),getMultipleNodeMembers()иfindAllByRoleIdAndNodeId(),trueуfindAllByEntityIds().Repository\UserRepository::findByNodeAndSearchQuery()и глобальный поискInternals\Service\Structure\UserService::searchByName()без$nodeId(приватныйsearchGlobal()) ищут только средиIS_REAL_USER = 'Y'.Service\NodeMemberService::saveUsersToDepartment()пропускает идентификаторы черезRealUserFilter::filterRealUserIds(), так что этим методом виртуального пользователя в отдел не добавить.
Сам модуль там, где виртуальные пользователи нужны, передаёт withVirtualUsers: true явно: в StructureAuthProvider, UserEventHandler, NewToOldEventHandler, StructureBackwardConverter и в DepartmentProvider для текущего пользователя. В StructureBackwardConverter рядом оставлен комментарий, что на членстве таких пользователей держатся права приложений. Если ваш код строит права по членству в отделах, делайте так же. Счётчикам этот флаг возвращает прежние числа, countByNodeId() с ним снова считает участников любого типа.
<?php declare(strict_types=1);
namespace Vendor\Hr\Structure;
use Bitrix\HumanResources\Builder\Structure\Filter\NodeFilter;
use Bitrix\HumanResources\Builder\Structure\Filter\NodeMemberFilter;
use Bitrix\HumanResources\Builder\Structure\NodeMemberDataBuilder;
use Bitrix\HumanResources\Util\NodeMemberCounterHelper;
use Bitrix\Main\Loader;
final class DepartmentAudience
{
private readonly NodeMemberCounterHelper $counter;
public function __construct()
{
Loader::requireModule('humanresources');
$this->counter = new NodeMemberCounterHelper();
}
/**
* Все участники отдела вместе с ботами: по этому списку приложение раздаёт права.
*
* @return list<int>
*/
public function userIdsForPermissions(int $departmentId): array
{
return NodeMemberDataBuilder::createWithFilter(
new NodeMemberFilter(
nodeFilter: NodeFilter::createWithNodeId($departmentId),
withVirtualUsers: true,
),
)
->getAll()
->getUniqueEntityIds();
}
public function headcount(int $departmentId, bool $withVirtualUsers = false): int
{
return $this->counter->countByNodeId(
$departmentId,
withAllChildNodes: true,
withVirtualUsers: $withVirtualUsers,
) ?? 0;
}
}
У методов NodeMemberRepository флага нет. Если вы брали участников через findAllByNodeId() и вам нужны виртуальные пользователи, соберите выборку через NodeMemberDataBuilder, как в userIdsForPermissions(). Старый findAllByNodeId() отдавал участников любого типа, для того же через builder передайте в фильтр ещё entityType: null.
Проверьте, какую активность отделов вы запрашиваете
До 26.500 значения NodeActiveFilter в Builder\Structure\Filter\NodeFilter работали наоборот: ONLY_ACTIVE фильтровал по GLOBAL_ACTIVE, а ONLY_GLOBAL_ACTIVE по ACTIVE. Теперь каждое значение фильтрует по своему полю. ONLY_GLOBAL_ACTIVE стоит по умолчанию в большинстве методов NodeRepository, поэтому из таких выборок начнут выпадать отделы с неактивным родителем. Код, который явно передаёт ONLY_ACTIVE, наоборот, начнёт получать активные отделы под неактивным родителем. Если значение когда-то подбирали по фактическому поведению, поменяйте его.
Ловите \DomainException при сохранении ролей
Service\Access\RolePermissionService::saveRolePermissions() проверяет права новым Access\Permission\RolePermissionValidator и бросает \DomainException, если:
- право не относится к категории роли;
- значение не int;
- значение не входит в список областей NONE/SELF/SELF+SUB/ALL (привязать право к конкретному отделу по id нельзя), а у переключателей не равно 0 или 1;
- одно право указано дважды;
- в командном праве NONE передан вместе с разрешающим значением.
То же исключение прилетит ещё в четырёх случаях: роль из чужой категории, переименование предустановленной роли, новое имя совпадает с именем предустановленной роли, удаление предустановленной роли через deleteRole() или deleteRoles(). Заранее это можно проверить новым публичным validateRolesCanBeDeleted().
Поменялась и сама запись. Она идёт в транзакции, и права роли теперь удаляются перед вставкой всегда. Раньше их удаляли, только если новая коллекция прав непустая, и сохранение с пустым набором прав старые права не трогало. Теперь оно их стирает, в том числе у элемента без ключа accessRights, например когда роль только переименовывают. Остальные исключения больше не заворачиваются в SqlQueryException и после rollback пробрасываются как есть, поэтому catch (SqlQueryException $e) вокруг вызова поймает меньше, чем раньше. В конструктор добавлены необязательные ?RolePermissionValidator $rolePermissionValidator и ?Connection $connection.
Компонент humanresources.config.permissions ловит \DomainException и отдаёт ошибку с кодом ROLE_DOMAIN_ERROR. В своём коде можно сделать так же. Числовые строки из формы приводить к int не нужно: saveRolePermissions() сам пропускает значения через is_numeric() и (int) до валидатора.
<?php declare(strict_types=1);
namespace Vendor\Hr\Access;
use Bitrix\HumanResources\Service\Container;
use Bitrix\Main\Error;
use Bitrix\Main\Loader;
use Bitrix\Main\Result;
final class RoleSettingsSaver
{
/**
* @param list<array{id: int|string, title: string, accessRights?: list<array{id: mixed, value: mixed}>}> $settings
*/
public function save(array $settings): Result
{
Loader::requireModule('humanresources');
$result = new Result();
try {
Container::getAccessRolePermissionService()->saveRolePermissions($settings);
} catch (\DomainException $e) {
$result->addError(new Error($e->getMessage(), 'ROLE_DOMAIN_ERROR'));
}
return $result;
}
}
Нечисловое значение раньше молча сохранялось как 0 ((int)$permission['value'] в старом коде), теперь на него валидатор отвечает \DomainException.
Не вызывайте installEvents() у humanresources
Из install/db/mysql и install/db/pgsql удалены install.sql, install_ft.sql и uninstall.sql. Из класса модуля в install/index.php пропали публичный installEvents() и приватный installAgents(). Вызов installEvents() не упадёт: он уйдёт в пустой CModule::InstallEvents() и ничего не зарегистрирует. Таблицы, обработчики событий и агенты теперь описаны в install/migrations/. Если ваш скрипт восстановления вызывал installEvents() у humanresources, сверяйте обработчики по install/migrations/events.php. При установке его применяет installMigrations() из installDB(). Что лежит в папке у humanresources, смотрите в разделе «БД».
Новое
Управляйте ролями через REST v3
Методы humanresources.access.* написаны на REST v3. Контроллеры наследуют Bitrix\Rest\V3\Controller\RestController и подхватываются из rest.defaultNamespace в .settings.php модуля. Что умеет этот базовый класс, мы разбирали в rest 26.600.0.
Rest\Controller\Access\Roleотвечает заlist,get,add,updateиdelete. Он работает с ролями категорий DEPARTMENT и TEAM,listпринимает ещё BOTH. Права передаются парами{permissionId, area}, у предустановленных ролей стоитisDefault.Rest\Controller\Access\Permission::listAction()отдаёт справочник прав по секциям с допустимыми значениями.Rest\Controller\Access\User::permissionsAction()отдаёт итоговые права сотрудника. Свои права можно смотреть с правом STRUCTURE_VIEW, чужие только с правом управлять доступом категории.
Вход разбирает Rest\RequestParams. assertAllowedFields() отвечает ошибкой валидации на лишний ключ, а has(), requireList(), requireNonEmptyString() и requirePositiveInt() проверяют остальное. Лишних полей в запрос не кладите.
Запрашивайте расчётный листок из 1С по ПИН-коду
По докблоку новый контроллер Controller\HcmLink\SalaryVacation обслуживает публичный поток расчётных листков и отпусков для мобильного приложения. Экшены companyList, requestPin, requestDocument и getResult (humanresources.HcmLink.SalaryVacation.*) принимают только POST и закрыты флагом Config\Feature::isHcmLinkSalaryVacationApiAvailable(). Флаг читает опцию hcmlink_salary_vacation_api_available модуля humanresources со значением N по умолчанию, так что после обновления поток выключен.
ПИН ходит по четырём шагам:
requestPinсоздаёт заданиеPIN_REQUESTи отправляет приложению 1С событиеOnHumanResourcesHcmLinkPinRequested.- 1С возвращает ПИН методом
humanresources.hcmlink.field.value.setв поле с кодомPIN. - Битрикс24 сразу отправляет ПИН тому же сотруднику pull-командой
salaryVacationPinReady({taskId, pin}) и отдаёт его вgetResultв полеpin. requestDocumentкладёт ПИН вOUTPUT_DATAзаданияSALARY_VACATION_REQUEST, а проверяет ПИН уже 1С.
Другого канала доставки ПИНа, SMS или почты, в коде нет. Включать флаг есть смысл, когда приложение 1С отвечает на OnHumanResourcesHcmLinkPinRequested и OnHumanResourcesHcmLinkSalaryVacationRequested, и делается это одной строкой: Option::set('humanresources', 'hcmlink_salary_vacation_api_available', 'Y'). Кроме флага, базовый HcmLinkController в processBeforeAction() требует Feature::isHcmLinkAvailable(), а тот пропускает только лицензию с регионом ru.
Классы, константы и события потока:
Service\HcmLink\SalaryVacationApiService(DIhumanresources.service.hcmlink.salaryVacationApi) с методамиgetCompanies,getOwnedCompanies,getOwnEmployee,isOwnEmployee,requestPin,request,getResult,getStatusиgetPin;Service\HcmLink\PinService(DIhumanresources.service.hcmlink.pin), егоdeliver()иdeliverDocument()шлют pull-командыsalaryVacationPinReadyиsalaryVacationDocumentReadyпользователюPerson.userId;Type\HcmLink\PayrollType(SALARY = 1,VACATION = 2),JobType::PIN_REQUEST = 6иJobType::SALARY_VACATION_REQUEST = 7;- REST-события
OnHumanResourcesHcmLinkPinRequestedиOnHumanResourcesHcmLinkSalaryVacationRequested; Contract\Service\HcmLink\JobService::ERROR_COMPANY_NOT_FOUNDсо значениемCOMPANY_NOT_FOUND(текст ошибки «Company not found»).
Для приложений со стороны 1С humanresources.hcmlink.field.value.set стал строже. Задание должно принадлежать компании, каждая запись должна совпадать с сотрудником и полем из задания, а статус DONE принимается, только когда пришли все значения (isCompleteJobResponse). Компанию метод ищет по GUID из 1С, по id CRM-компании или по заданию.
Для агента прав Марты нужен модуль aiassistant
В секции aiassistant.marta файла .settings.php модуль регистрирует агента Integration\AiAssistant\Agents\AccessAgent с кодом access и набор ToolSets\AccessToolSet из шести инструментов: hr_role_list, hr_role_permissions, hr_user_permissions, hr_create_role, hr_update_role и hr_delete_role. У инструментов общий предок AccessBaseTool и общая схема прав InputProperty::accessRights().
Каждый инструмент наследует Bitrix\AiAssistant\Definition\Tool\Contract\ToolContract и реализует getName(), getDescription(), getInputSchema() с JSON Schema, execute(int $userId, ...$args): string, canList() и canRun(). Enum и обязательные поля проверяет валидатор входа в ToolManager::callTool. По комментарию в AccessBaseTool, в MCP-контексте CurrentUser::getId() возвращает 0, поэтому пользователь приходит только аргументом execute().
Модуля aiassistant (namespace Bitrix\AiAssistant) нет ни в коробочном Битрикс24, ни в «Управлении сайтом», так что запустить агента в коробке не на чем. Секцию aiassistant.marta при этом объявляют уже 11 модулей: biconnector, bizprocdesigner, booking, crm, humanresources, im, intranet, landing, mail, socialnetwork и tasks.
Читайте роли и права новыми методами
Для ролей появились RoleRepository::getRoleById(), getRolesByIds() и updateName(), а также PermissionRepository::getPermissionListByRoleIds(), который выбирает чанками по 300. В RolePermissionService добавили getRoleById(), getRoleAccessRightsMap(), getCanonicalRoleAccessRights() и getCanonicalRoleAccessRightsMap(). В канонической форме командные права сворачиваются обратно в пары значений.
Внутренний Internals\Repository\Structure\NodeRepository::findAllByIds() получил параметр ?int $viewerUserId = null и проверяет доступ от имени этого пользователя.
БД
Новых таблиц и полей нет. Переписан install/index.php:
installDB()вместо$DB->runSQLBatch(install.sql)вызываетinstallMigrations();uninstallDB()вызываетuninstallMigrations()с$dropTables = false, так что при удалении модуля таблицы остаются. РаньшеuninstallDB()просто возвращалtrue, иuninstall.sqlтоже не выполнялся.
install/migrations/tables.php повторяет удалённый install.sql один в один: 22 таблицы (b_hr_structure*, b_hr_access_*, b_hr_log, b_hr_hcmlink_*) с теми же колонками и индексами, включая два FULLTEXT, IXF_B_HR_STRUCTURE_NODE_NAME и IXF_B_HR_HCMLINK_PERSON_INDEX_SEARCH_CONTENT. Мы сверили его поколоночно. agents.php регистрирует те же пять агентов, events.php те же обработчики iblock, main, humanresources и rest. В migration_config.json defaultTableName равен b_hr_structure_node_backward_access_code, там же маппинг install/components, install/js и install/activities.
ПИН и документы HCM Link пишутся в существующие таблицы. Задания лежат в b_hr_hcmlink_job с TYPE 6 или 7, в OUTPUT_DATA у них ключи company, companyGuid, employees, persons, date и type, у запроса документа ещё fields, period и pin. Значения лежат в b_hr_hcmlink_field_value под id задания (documentIdByEmployeeId). Флаг hcmlink_salary_vacation_api_available хранится строкой в b_option.
Мелочи и находки
- ПИН в
OUTPUT_DATAзаданияSALARY_VACATION_REQUESTлежит в базе открытым текстом.getResultвырезаетoutputData.pinиз ответа, ноgetPin(), который по докблоку нужен для диагностики при разработке, вызывается из рабочегоgetStatus(). - Повторный
requestPinв течение 60 секунд возвращает то же задание, если оно в статусе STARTED или IN_PROGRESS. Кроме того, для ПИНа и для документа отдельно на 60 секунд выдаётся слот на связку «пользователь, компания, сотрудник, тип» черезPersistentStorageInterfaceподConnection::lock(). Без свободного слота приходит ошибкаTOO_MANY_REQUESTS.SalaryVacationApiService::acquireRequestSlot()годится как рецепт TTL-троттлинга без Redis и своих таблиц для любой кнопки вроде «отправить код» или «запросить выгрузку». JobKillerServiceзаданияPIN_REQUESTиSALARY_VACATION_REQUESTповторно не пересылает и отменяет по TTL. Повторное событие, как сказано в комментарии, заставило бы 1С выдать лишний ПИН или прислать устаревший.getStatus()на чужой или несуществующийtaskIdотвечает EXPIRED и не раскрывает, существует ли задание.- Для расчётного листка запрашивается одно поле с кириллическим кодом
РасчётныйЛисток(сущность DOCUMENT, месяц и год обязательны). Для отпуска контракта полей пока нет, и, по комментарию в коде, первое значение может оказаться посторонними данными сотрудника. - Для заданий типов 6 и 7
JobEventHandlerне шлёт pullexternal_employee_list_updated. - Агенты CompanyStructure и Department и
SearchEmployeeToolпроверяли$user->getPermission(STRUCTURE_VIEW). Вызов мог вернуть null, а условиеnull !== VARIABLE_NONEпропускало пользователя. Теперь значение берётся черезPermissionHelperс NONE по умолчанию. StructureAccessControllerкешировал правила вstatic $ruleHandlerвнутри метода, хотя правило создаётся с$thisконтроллера. В одном хите правило первого пользователя получали контроллеры остальных. Кеш переехал в свойство экземпляра.- Пункт вендора «Усилена безопасность отображения названий подразделений» — это
ResponsiveHint.ui.hintрендерит подсказку через innerHTML, и теперь текст передshow()проходит черезText.encode(). OnAfterUserAddс сортировкой 9 регистрируется вevents.phpстарымEventManager::registerEventHandler()в зависимости отDatabaseUpdateMode::ModuleInstallилиModuleUninstall. Комментарий объясняет это тем, чтоregister()не принимает sort, но с main 26.650.0 уMigration\Event::register()есть параметр?int $sort. В своём модуле порядок задавайте им:->register('main', 'OnAfterUserAdd', Handler::class, 'onAfterUserAdd', 9).- Исправили роль руководителя при переводе сотрудника.
Internals\Service\Structure\NodeMemberService::moveMember()при переносе без роли в другой узел ставит роль по умолчанию целевого узла, аNewToOldEventHandler::clearSourceDepartmentHead()снимаетUF_HEADу прежнего отдела, если сотрудник там больше не руководитель. HcmLink\Mapper::endAction()обращался к$company->idу null, когда компании нет. Теперь там проверкаcheckCompanyExists()и код ошибкиERROR_COMPANY_NOT_FOUND.- В мастере оргструктуры уже добавленного сотрудника можно назначить руководителем или заместителем. Во фронте
releaseMemberRole()снимает прежнюю роль, а селекторы руководителя и заместителя больше не прячут занятых участников. - Действие БП
humanresourcesgetaireportusersactivityскрывается черезsetExcludedи возвращает ошибку, когдаBizproc\Public\Service\AiAgent\NodeAvailabilityServiceInterface::isAvailable()отвечает false. Так сделано региональное ограничение ИИ. - Фильтр ботов для своих запросов можно собрать по образцу
RealUserFilter. Это подзапросUserTable::query()->where('IS_REAL_USER', 'Y')внутриwhereInи суффикс в ключе кеша. Сам класс лежит вInternals, поэтому приём надёжнее повторить у себя, чем вызывать класс напрямую.