Одна из новых возможностей HRM-модуля ZentrySpace — блок «Процессы». На первом этапе он закрывает сценарии отсутствий: отпуска, больничные, командировки, учебные отпуска, отгулы и другие ситуации, когда сотрудник временно выпадает из рабочего графика.

Раньше подобные сценарии мы называли «заявками»: сотрудник создает заявку, руководитель согласовывает, HR-менеджер фиксирует результат. Но по мере развития продукта стало понятно, что слово «заявка» описывает только входную форму, а не всю ценность. На самом деле компания получает управляемый бизнес-процесс: с маршрутом, ролями, действиями, уведомлениями, историей изменений и будущей связкой с КЭДО.

Поэтому блок сменит название на «Процессы», заявка при этом остается способом для запуска процесса со стороны пользователя, но продуктовая сущность шире: это визуальный движок, с помощью которого компания может закрывать типовые и нетиповые внутренние процессы без разработки отдельной логики под каждый сценарий.

Какие процессы можно закрывать через блок:

  • плановые и внеплановые отпуска;

  • листы нетрудоспособности;

  • командировки;

  • учебные отпуска;

  • отпуска за свой счет;

  • оформление пропусков; 

  • смена прав доступа к системе;

  • пересмотр заработной платы;

  • замена или покупка корпоративного оборудования;

  • получение справок;

  • обучение или повышение квалификации;

  • смена персональных данных;

  • отгулы по разного рода обстоятельствам.

Процессы помогают делать работу компании более управляемой и предсказуемой, что позволяет бизнесу в любой сфере не терять деньги и не проседать из-за неэффективной работы с человеческими ресурсами.

Особенности разработки

Разработка шла в три итерации. Это важная часть истории, потому что именно через итерации мы пришли от простой формы согласования к архитектуре визуального бизнес-процесса.

Технически мы рассматривали блок «Процессы» не как на набор CRUD-экранов, а как доменную модель. Внутри такой модели есть шаблон процесса, версия шаблона, экземпляр процесса, узлы маршрута, переходы между ними, действия пользователей и журнал событий. Это разделение важно: интерфейс может меняться, но исполняемая модель процесса должна оставаться стабильной.

Доменная модель

Приведем пример кода, чтобы объяснить сам принцип: процесс нельзя хранить как «набор экранов» или «список статусов». Его лучше хранить как версионируемый граф. Узлы отвечают на вопрос “где сейчас находится процесс”, а связи — “какое действие переводит его на следующий этап”. Именно это делает блок похожим на workflow-движок, а не на обычный конструктор форм. 

Если хранить процесс как обычную карточку заявки, ветвления, возвраты и КЭДО быстро превращаются в набор исключений. Графовая модель делает эти сценарии штатной частью архитектуры.

Пример кода

type ProcessTemplate = {
  id: string;
  version: number;
  title: string;
  fields: ProcessField[];
  flow: ProcessFlow;
  status: 'draft' | 'active' | 'archived';
};
type ProcessFlow = {
  nodes: ProcessNode[];
  edges: ProcessEdge[];
};
type ProcessNode = {
  id: string;
  kind: 'start' | 'approval' | 'hr_review' | 'document' | 'signing' | 'final';
  title: string;
  assigneePolicy?: AssigneePolicy;
};
type ProcessEdge = {
  id: string;
  sourceNodeId: string;
  targetNodeId: string;
  action: {
    code: string;
    title: string;
  };
  guard?: TransitionGuard;
};

В такой модели название узла не является просто подписью на карточке. Это состояние процесса. А действие на связи — не декоративная стрелка, а исполняемый переход: «согласовать», «вернуть на доработку», «отклонить», «передать в HR-отдел», «сформировать документ». За счет этого процесс становится не только визуальным, но и исполняемым.

То есть мы разделяем «шаблон процесса» и «экземпляр процесса». Это позволяет строить разные бизнес-процессы на одном движке, не переписывая код под каждый тип отсутствия или согласования.

Конструктор процессов в продукте стал для нас не просто отдельной функцией, а одним из базовых элементов всей будущей операционной логики продукта. Если смотреть на него формально, сначала это был классический сценарий: администратор создает тип процесса, задает поля, назначает ответственных, описывает этапы прохождения и действия на каждом шаге. Такой подход хорошо ложился на исходное ТЗ и позволял быстро собрать первый рабочий вариант.

Первая итерация была про быстрый запуск основы: для нас было важно не строить сразу тяжелый универсальный BPM-инструмент, а проверить саму модель, понять, как пользователи мыслят отсутствиями, датами, ответственными и маршрутом. На этом этапе конструктор был ближе к привычной административной форме с последовательностью вех.

Вторая итерация стала этапом проверки на реальных сценариях. Именно тогда проявилось главное ограничение ранней модели: вехи, которые создавались одна за другой как карточки, подходили для простых процессов, но практически ломались в момент, когда появлялись ветвления, возвраты, обходные пути, разные действия внутри одной точки согласования. Настроить такую логику формально можно было и в старом формате, но читать, поддерживать и масштабировать ее становилось слишком сложно.

Третья итерация стала переломной. Мы отказались от карточной линейной модели отображения вех и перевели маршрутизацию в визуальный граф на базе React Flow. Это изменение было не косметическим, а архитектурным. Вехи стали полноценными узлами процесса, а действия — явными связями между ними.

React Flow как визуальный слой, а не бизнес-логика

Одна из технических тонкостей — не смешивать библиотеку визуализации и доменную модель. React Flow отлично подходит для отображения узлов, drag-and-drop, связей, выделения ошибок и пользовательского редактирования схемы. Но если хранить бизнес-логику прямо в формате UI-библиотеки, продукт станет зависим от фронтенд-представления. Поэтому React Flow уместнее рассматривать как редактор графа, а не как источник истины.

Следующий пример показывает границу между UI и доменом. Сначала мы читаем визуальные узлы и связи, затем приводим их к собственной модели процесса. Это позволяет завтра изменить библиотеку отображения, не переписывая исполнение процессов, права, валидацию и журнал событий.

Таким образом мы не привязываем бэкенд, права, исполнение и КЭДО к конкретной UI-библиотеке. Если визуальный редактор изменится, процессная модель и история исполнения останутся стабильными.

Пример кода

function mapReactFlowToDomain(
  nodes: ReactFlowNode[],
  edges: ReactFlowEdge[]
): ProcessFlow {
  return {
    nodes: nodes.map((node) => ({
      id: node.id,
      kind: node.data.kind,
      title: node.data.title,
      assigneePolicy: node.data.assigneePolicy,
    })),
    edges: edges.map((edge) => ({
      id: edge.id,
      sourceNodeId: edge.source,
      targetNodeId: edge.target,
      action: {
        code: edge.data.actionCode,
        title: String(edge.label ?? edge.data.actionTitle),
      },
      guard: edge.data.guard,
    })),
  };
}

Здесь все просто: визуальная схема должна быть удобной для администратора, но исполняться должна нормализованная доменная модель. Это снижает связанность фронтенда и бэкенда, упрощает тестирование и делает процесс пригодным для будущего расширения — например, для КЭДО, уведомлений и интеграций.

По факту это хороший компромисс между скоростью разработки и архитектурной устойчивостью. React Flow дает готовую механику редактора, а собственная доменная модель защищает продукт от зависимости «бизнес-логика = формат UI-компонента».

Валидация графа до публикации шаблона

Для администратора важно не только нарисовать схему, но и понять, почему она не может быть опубликована. Поэтому валидация должна работать на уровне графа, а ошибки — быть привязанными к конкретным узлам и связям. В этом примере кода заложен продуктовый принцип: сложность процесса должна контролироваться системой до того, как процесс начнут использовать реальные сотрудники.

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

Пример кода

type FlowValidationError = {
  code:
    | 'NO_START_NODE'
    | 'NO_FINAL_NODE'
    | 'UNREACHABLE_NODE'
    | 'EDGE_WITHOUT_ACTION'
    | 'BROKEN_EDGE'
    | 'NODE_WITHOUT_ASSIGNEE';
  message: string;
  nodeId?: string;
  edgeId?: string;
};
function validateFlow(flow: ProcessFlow): FlowValidationError[] {
  const errors: FlowValidationError[] = [];
  const nodeIds = new Set(flow.nodes.map((node) => node.id));
  const startNodes = flow.nodes.filter((node) => node.kind === 'start');
  const finalNodes = flow.nodes.filter((node) => node.kind === 'final');
  if (startNodes.length !== 1) {
    errors.push({ code: 'NO_START_NODE', message: 'В процессе должна быть одна стартовая точка' });
  }
  if (finalNodes.length === 0) {
    errors.push({ code: 'NO_FINAL_NODE', message: 'Добавьте хотя бы один финальный этап' });
  }
  for (const edge of flow.edges) {
    if (!nodeIds.has(edge.sourceNodeId) || !nodeIds.has(edge.targetNodeId)) {
      errors.push({ code: 'BROKEN_EDGE', edgeId: edge.id, message: 'Связь указывает на несуществующий этап' });
    }
    if (!edge.action.code || !edge.action.title) {
      errors.push({ code: 'EDGE_WITHOUT_ACTION', edgeId: edge.id, message: 'Для перехода не задано действие' });
    }
  }
  const reachable = collectReachableNodes(flow, startNodes[0]?.id);
  for (const node of flow.nodes) {
    if (!reachable.has(node.id)) {
      errors.push({ code: 'UNREACHABLE_NODE', nodeId: node.id, message: 'Этап недостижим из стартовой точки' });
    }
  }
  return errors;
}

Такой механизм хорошо ложится на UX: ошибки можно подсвечивать прямо на схеме. Пользователь не читает длинный список технических ограничений, а сразу видит, какой узел или переход нужно исправить. Для HRM-системы это особенно важно, потому что настройкой процессов могут заниматься не разработчики, а администраторы компании.

Почему мини-n8n?

Ссылка на n8n в названии появилась не ради красивой аналогии или привлечения внимания. Нам действительно близка идея визуального конструктора, где из понятных блоков собирается рабочая логика. В n8n пользователь соединяет узлы, описывает переходы данных и получает исполняемый workflow. В ZentrySpace мы решаем похожую задачу, но в другом контексте: не интеграции внешних API, а внутренние корпоративные процессы.

n8n важен нам не как бренд-референс, а как понятная инженерная метафора: сложный сценарий можно представить не как длинную форму настроек, а как сеть узлов и переходов. Такой подход снижает когнитивную нагрузку: администратор видит не абстрактный список правил, а карту будущего поведения системы.

Если проводить аналогию, то узел в нашем случае — это доменное состояние процесса: согласование руководителем, проверка HR-менеджером, ожидание документа, подписание, финальное закрытие. Ребро — это действие пользователя или системы, которое переводит процесс в следующее состояние. А весь граф — это исполняемая схема, понятная не только разработчику, но и бизнес-администратору.

Наши узлы изначально знают про оргструктуру, роли, руководителей, HR, уведомления, документы и права доступа. Это делает конструктор менее универсальным, чем n8n, но гораздо более точным для ежедневных процессов компании.

Когда визуальный граф становится рабочим процессом?

Когда пользователь нажимает действие, мы не просто меняем статус в карточке. Система должна проверить доступность перехода из текущего узла, права пользователя, выполнить изменение атомарно и оставить событие в журнале. Иначе визуальный конструктор останется только красивым интерфейсом, но не станет надежным workflow-движком.

Запись события происходит в той же транзакции, что и смена состояния. Это защищает аудит: если процесс перешел дальше, в истории должно быть объяснение, кто и каким действием это сделал. В типовой форме можно просто поменять поле status. В процессном движке так делать нельзя: действие допустимо только из конкретного узла, конкретным пользователем и по правилам конкретной версии шаблона.

В примере кода показан момент, когда процесс становится исполняемым. Пользователь нажимает действие, но система должна проверить маршрут, права, атомарно изменить состояние и записать событие.

Пример кода

async function executeProcessAction(params: {
  processId: string;
  actorId: string;
  actionCode: string;
}) {
  const process = await processRepository.getById(params.processId);
  const template = await templateRepository.getVersion(
    process.templateId,
    process.templateVersion
  );
  const transition = template.flow.edges.find((edge) =>
    edge.sourceNodeId === process.currentNodeId &&
    edge.action.code === params.actionCode
  );
  if (!transition) {
    throw new Error('Действие недоступно на текущем этапе процесса');
  }
  await permissionService.assertCanExecute({
    actorId: params.actorId,
    process,
    transition,
  });
  await processRepository.transaction(async (tx) => {
    await tx.processes.update(params.processId, {
      currentNodeId: transition.targetNodeId,
      updatedAt: new Date(),
    });
    await tx.processEvents.insert({
      processId: params.processId,
      actorId: params.actorId,
      type: 'ACTION_EXECUTED',
      payload: {
        actionCode: params.actionCode,
        fromNodeId: transition.sourceNodeId,
        toNodeId: transition.targetNodeId,
      },
      createdAt: new Date(),
    });
  });
  await notificationService.notifyNextAssignees(params.processId);
}

Эта логика и делает реализацию отличающейся от типового конструктора форм. Во многих системах процесс фактически сводится к полям и статусам. У нас действие является частью маршрута, маршрут — частью версии шаблона, а каждое исполнение оставляет след. За счет этого процесс можно объяснить, восстановить и безопасно развивать.

Версионирование шаблонов: почему нельзя просто редактировать активный процесс

Еще одна тонкая часть реализации — версионирование. Когда по шаблону уже запущены реальные процессы, его нельзя бесконтрольно менять «поверх». Иначе старый процесс может оказаться на этапе, которого больше нет, или получить действие, которого раньше не существовало. Поэтому каждый запущенный экземпляр должен ссылаться на конкретную версию шаблона. Запущенный процесс должен помнить не только templateId, но и templateVersion, потому что правила маршрута со временем меняются.

Пример кода

type ProcessInstance = {
  id: string;
  templateId: string;
  templateVersion: number;
  currentNodeId: string;
  payload: Record<string, unknown>;
  status: 'in_progress' | 'completed' | 'rejected' | 'cancelled';
};
async function publishTemplateDraft(templateId: string) {
  const draft = await templateRepository.getDraft(templateId);
  const errors = validateFlow(draft.flow);
  if (errors.length > 0) {
    return { ok: false, errors };
  }
  return templateRepository.publishNewVersion({
    templateId,
    fields: draft.fields,
    flow: draft.flow,
  });
}

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

Фундамент для КЭДО

Еще одно важное отличие в том, что блок «Процессы» — фундамент для следующего уровня автоматизации — КЭДО. И здесь особенно важно, что процесс — это не просто запись в системе и не просто цепочка согласований, а управляемый маршрут.

С точки зрения архитектуры КЭДО логично добавлять не как отдельную «кнопку подписи», а как тип узла или системное действие внутри маршрута. Например, после согласования руководителем процесс может перейти на этап формирования документа, затем — подписания, а после успешной подписи — в финальное состояние.

Код ниже показывает место КЭДО в модели процесса. Подписание становится частью маршрута: у него есть документ, политика выбора подписанта, тип подписи, формат результата и запись проверки. Это важно, потому что юридически значимое действие не должно жить отдельно от истории процесса.

Пример кода

type DocumentSigningNode = {
  nodeId: string;
  documentTemplateId: string;
  signerPolicy: 'process_author' | 'manager' | 'hr_specialist' | 'custom_role';
  signatureType: 'simple_electronic_signature' | 'enhanced_unqualified_signature';
  outputFormat: 'PDF_A';
};
type SignatureRecord = {
  id: string;
  processId: string;
  documentId: string;
  signerUserId: string;
  signerSnapshot: {
    fullName: string;
    email: string;
    position?: string;
  };
  signedAtUtc: string;
  originalDocumentHash: string;
  signedViewHash?: string;
  verificationMethod: 'otp' | 'push' | 'totp';
};

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

Событийная модель: почему каждое действие должно оставлять след

Для корпоративного продукта критически важно (в том числе юридически), чтобы процесс был объяснимым задним числом для восстановления цепочки событий при необходимости. Поэтому помимо текущего состояния нужен журнал событий. Текущее состояние отвечает на вопрос “где сейчас находится процесс”, а event log отвечает на вопрос “какое действие его сюда привело”.

Пример кода

type ProcessEvent = {
  id: string;
  processId: string;
  actorId?: string;
  type:
    | 'PROCESS_CREATED'
    | 'ACTION_EXECUTED'
    | 'ASSIGNEE_CHANGED'
    | 'DOCUMENT_GENERATED'
    | 'SIGNATURE_REQUESTED'
    | 'DOCUMENT_SIGNED'
    | 'PROCESS_COMPLETED';
  payload: Record<string, unknown>;
  createdAt: string;
};
await eventBus.publish({
  type: 'PROCESS_ACTION_EXECUTED',
  processId,
  actorId,
  payload: {
    actionCode: 'approve',
    from: 'manager_approval',
    to: 'hr_review',
  },
});

На этом же слое удобно строить уведомления: отправить сообщение руководителю, показать кадровому отделу новую задачу, обновить счетчик в интерфейсе, записать событие для аналитики. Важно, что уведомления не должны быть единственным источником истины. Источник истины — состояние процесса и журнал событий.

Три итерации — избыточно?

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

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

Так, простая заявка привела нас к новуму блоку и дала продукту три важных преимущества: визуальную читаемость для администратора, контролируемую техническую модель для разработки и архитектурный фундамент для расширения.