Skip to content

BPMN-процессы

Orbita исполняет подмножество BPMN 2.0 с расширениями Camunda и собственным namespace orbit. Схема хранится в process_templates.structure_xml; движок разбирает XML в плоские массивы узлов и дуг и исполняет узлы по одному.

Модуль может быть выключен

Функция бизнес-процессов включается оператором платформы — глобально и отдельно по тенантам. При выключенном модуле API процессов возвращает 403/404, старт по событиям не срабатывает, планировщик таймеров пропускает тенант, а разделы процессов скрыты из интерфейса.

Слои

КлассОтветственность
ProcessDefinitionParserXML → nodes / flows. Единственное место, знающее про XML
ProcessDefinitionCompilerстарт-подписки шаблона из узлов «По событию»
ProcessStartServiceзапуск экземпляра: режим ручного старта, роли, условия, запрет дублей
ProcessRuntimeadvance / executeNode / selectFlows / evaluateCondition, таймеры, завершение задач
NodeExecutors\*исполнение конкретного типа узла
ProcessEventBusсобытия записи → старт новых экземпляров и пробуждение ждущих
TaskBridgeзадачи процесса ↔ записи сущностей-задач
TaskKindRegistryкакие сущности считаются задачами
ProcessGraphValidatorпроверки схемы, зеркалящие правила рантайма

Как работает движок

  1. Экземпляр создаётся ProcessStartService — вручную с карточки, действием автоматизации start_process, узлом callActivity или ProcessEventBus по подписке. payload заполняется данными записи, process_variables инициализируются из него.
  2. ProcessRuntime::advance() берёт исходящие дуги узла, отбирает их через selectFlows() и вызывает executeNode() для каждой цели.
  3. executeNode() отдаёт узел исполнителю из NodeExecutors. Автоматические узлы отрабатывают и сами вызывают advance(); userTask создаёт запись задачи и останавливает ветку.
  4. Завершение задачи = обновление её записи. TaskBridge ловит это и зовёт ProcessRuntime::onTaskCompleted(), который пишет переменные решения и продолжает поток.
  5. Ошибка узла уходит в handleNodeError(): при навешенном errorBoundary поток идёт по ветке ошибки, иначе экземпляр падает с last_error (и ошибка поднимается в родительский callActivity, если он есть).

Заготовка XML

xml
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions
  xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
  xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
  xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
  xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
  xmlns:camunda="http://camunda.org/schema/1.0/bpmn"
  xmlns:orbit="http://orbit"
  targetNamespace="http://bpmn.io/schema/bpmn">

  <bpmn:process id="my_process" isExecutable="true">
    <!-- узлы и дуги -->
  </bpmn:process>
</bpmn:definitions>

Namespace orbit (http://orbit) несёт всё, чего нет в Camunda: события сущности, действие set_state, кворум multi-instance, списки групп и подразделений.

Парсер читает элементы: startEvent, endEvent, task, userTask, serviceTask, sendTask, scriptTask, exclusiveGateway, parallelGateway, inclusiveGateway, intermediateCatchEvent, boundaryEvent, callActivity.

Элементы редактора и теги XML

Редактор оперирует более дробным набором типов, чем BPMN. Различаются они атрибутом, а не тегом:

Элемент редактораТегПризнак
СогласованиеuserTaskcamunda:formKey="approval"
УведомлениеserviceTaskcamunda:type="notification"
Смена статусаserviceTaskorbit:action="set_state"
По событиюstartEventbpmn:messageEventDefinition + orbit:eventType
Ожидание событияintermediateCatchEventbpmn:messageEventDefinition
Пауза по таймеруintermediateCatchEventbpmn:timerEventDefinition
SLA-таймерboundaryEventbpmn:timerEventDefinition + attachedToRef
Обработчик ошибкиboundaryEventbpmn:errorEventDefinition + attachedToRef

Запуск экземпляра

Старт по событию записи

ProcessDefinitionCompiler заводит строку в process_subscriptions для каждого startEvent с непустым orbit:eventType. Обычный startEvent без него подписки не даёт — такой шаблон стартует только вручную, автоматизацией или как подпроцесс.

xml
<bpmn:startEvent id="start" name="Заявка создана"
                 orbit:eventType="record_created">
  <bpmn:messageEventDefinition/>
</bpmn:startEvent>
xml
<bpmn:startEvent id="start_escalated" name="Приоритет поднят"
                 orbit:eventType="field_changed"
                 orbit:eventField="priority"
                 orbit:eventValue="critical"
                 orbit:eventCondition="${status != 'closed'}">
  <bpmn:messageEventDefinition/>
</bpmn:startEvent>
orbit:eventTypeКогда матчится
record_createdзапись создана
record_updatedзапись обновлена
field_changedeventField попало в изменённые поля; eventValue, если задан, сверяется со значением
state_changedто же для поля состояния
signalстарт-подписки не даёт — только пробуждение ждущего узла

field_changed и state_changed требуют, чтобы поле было в changedFields события: сохранение записи без изменения этого поля старта не даёт.

orbit:eventCondition считается тем же вычислителем, что и условия дуг, — по данным записи.

Глубина цепочки

ProcessEventBus синхронно живёт внутри пайплайна сохранения. Цепочка «событие → процесс → сохранение → событие» ограничена MAX_DEPTH = 5; на превышении цепочка обрывается с записью в лог.

Ожидание события

intermediateCatchEvent с messageEventDefinition заводит resume-подписку и останавливает ветку. Корреляция:

  • по умолчанию — привязанная запись экземпляра;
  • orbit:correlationVariable="имя" — id записи из переменной процесса;
  • orbit:eventType="signal" + orbit:signalName — явный сигнал от действия автоматизации signal_process.

Подписка одноразовая: срабатывает и удаляется.

Ручной запуск и дубли

Настройки живут на шаблоне, не в XML:

КолонкаЗначения
manual_start_modedisabled (по умолчанию) / all / roles
manual_start_rolesсписок id ролей, только в режиме roles
manual_start_conditions{logic: and|or, conditions: [...]} по полям записи
parallel_modenone / per_record / global

ProcessStartService применяет их на всех путях запуска. Нарушение parallel_mode бросает DuplicateProcessInstanceException (HTTP 409). Эндпоинт GET /api/sys/process_template/startable отдаёт шаблоны, доступные пользователю на конкретной записи, с blocked_reason у заблокированных.


Типы узлов

scriptTask — PHP-скрипт

Доступны $api (PublicApiHelper), $payload (переменные процесса, по ссылке) и $entityId.

xml
<bpmn:scriptTask id="s_notify" name="Уведомить заявителя">
  <bpmn:script><![CDATA[
    $record = $api->get('it_requests', $entityId);
    if (!empty($record['requested_by'])) {
        $api->sendPushNotification(
            'Заявка принята',
            'Ваша заявка «' . $record['name'] . '» зарегистрирована.',
            '/e/it_requests/' . $entityId,
            [$record['requested_by']]
        );
    }
  ]]></bpmn:script>
</bpmn:scriptTask>

Ошибки скрипта прерывают процесс

Исключение из скрипта пробрасывается: при навешенном errorBoundary поток идёт по ветке ошибки, иначе экземпляр падает со статусом failed и заполненным last_error.

$payload — массив по ссылке; изменения в нём сохраняются в process_variables, но записи в базе не меняют.

Записать в саму запись можно двумя путями, и они не равнозначны:

ПутьЧто срабатывает
serviceTask c update_field_*полный пайплайн: вычисляемые поля, автоматизации, события процессов, аудит
$api->updateRecord() из скриптапрямая запись полей мимо автоматизаций и событий процессов
$api->create() из скриптаполный пайплайн создания, включая событие record_created

Скрипт внутри процесса намеренно не должен ретриггерить автоматизации, поэтому updateRecord тихий. Если изменение обязано разбудить другой процесс — делайте его serviceTask.

serviceTask — обновление полей

xml
<bpmn:serviceTask id="s_close" name="Закрыть заявку">
  <bpmn:extensionElements>
    <camunda:properties>
      <camunda:property name="update_field_status"   value="resolved"/>
      <camunda:property name="update_field_priority" value="low"/>
    </camunda:properties>
  </bpmn:extensionElements>
</bpmn:serviceTask>

Свойства вида update_field_{поле} уходят в действие update_fields того же ActionRegistry, что и автоматизации: полноценный пайплайн обновления — вычисляемые поля, автоматизации, аудит, индексация. Если запись к экземпляру не привязана, значения только кладутся в переменные процесса.

serviceTask + orbit:action="set_state" — смена статуса

xml
<bpmn:serviceTask id="s_state" name="В работу"
                  orbit:action="set_state"
                  orbit:stateField="status"
                  orbit:stateValue="in_work"/>

Идёт через действие set_state с проверкой модели состояний. Недопустимый переход бросает ошибку — её ловит errorBoundary узла либо падает экземпляр.

serviceTask + camunda:type="notification" — уведомление

Узел рассылает по списку назначений; каждое назначение — свой канал и свои получатели.

xml
<bpmn:serviceTask id="s_notify" name="Уведомить" camunda:type="notification">
  <bpmn:extensionElements>
    <camunda:properties>
      <camunda:property name="notification_targets"
                        value="[{&quot;channel&quot;:&quot;push&quot;,&quot;recipients&quot;:&quot;${assignee}&quot;}]"/>
      <camunda:property name="notification_template" value="task_assigned"/>
    </camunda:properties>
  </bpmn:extensionElements>
</bpmn:serviceTask>
СвойствоНазначение
notification_targetsJSON-массив назначений: канал, получатели, параметры канала
notification_templateкод шаблона сообщения
notification_subject, notification_custom_textтекст, если шаблон не задан

Плоские notification_channel* — форма узлов, нарисованных до мультиканальности; парсер разворачивает их в notification_targets. Ошибка отправки логируется и процесс не останавливает.

sendTask — отправка в исходящий канал

Из палитры убран: его работу делает узел «Уведомление». Нарисованные раньше узлы читаются, редактируются и исполняются по-прежнему — свойства channel_id, channel_type, send_template, send_subject, send_text, send_recipients, send_payload.

userTask — задача и согласование

Приостанавливает ветку и создаёт запись сущности-задачи.

xml
<bpmn:userTask id="t_resolve" name="Решить проблему"
               camunda:assignee="${assignee}"/>
xml
<bpmn:userTask id="t_accept" name="Принять заявку"
               camunda:candidateGroups="{{ROLE:IT Support Manager}}"
               orbit:candidateGroupsList="3,7"
               orbit:candidateOrgUnitsList="12"/>

Согласование — тот же userTask с camunda:formKey="approval"; оно пишет переменные решения, обычная задача — нет.

Свойство / атрибутНазначение
camunda:assigneeконкретный исполнитель, выражение по переменным
camunda:candidateGroupsроли; формат ROLE:Название в двойных фигурных скобках — как в примере выше
orbit:candidateGroupsList, orbit:candidateOrgUnitsListid групп и подразделений через запятую
task_entityslug сущности задач; пусто — системная «Задачи»
instructionинструкция исполнителю, видна в панели процессов
sla_durationдедлайн задачи без эскалации (ISO 8601)
complete_on_field_{поле}автозавершение при обновлении записи
approval_typeтип согласования (форма деталей)
details_field_{поле}заполнение поля формы из записи или переменной
require_preparation, preparer_sourceэтап подготовки согласования

Нестрогое сравнение

complete_on_field_ использует ==, не ===: "1" == 1 совпадёт. Задавайте строковые значения из value_list / state_model.

Multi-instance — N исполнителей и кворум

xml
<bpmn:userTask id="t_vote" name="Согласование комиссией" camunda:formKey="approval">
  <bpmn:multiInstanceLoopCharacteristics isSequential="false"
      orbit:assigneesSource="${committee}"
      orbit:quorum="majority"/>
</bpmn:userTask>

orbit:quorum: all (по умолчанию), majority, count:N, first. По достижении решения оставшиеся задачи активации отменяются, в переменные пишутся decision, decision_reason, approved_count, rejected_count, votes_total.

callActivity — подпроцесс

xml
<bpmn:callActivity id="sub" name="Согласование бюджета"
                   calledElement="budget_approval"
                   orbit:wait="true"
                   orbit:bindEntity="parent">
  <bpmn:extensionElements>
    <camunda:in  source="amount"   target="requested_amount"/>
    <camunda:out source="decision" target="budget_decision"/>
  </bpmn:extensionElements>
</bpmn:callActivity>

calledElementuid вызываемого шаблона. orbit:wait="false" запускает подпроцесс и идёт дальше не дожидаясь. camunda:in / camunda:out переносят переменные внутрь и обратно; ошибка подпроцесса поднимается в родителя.

Шлюзы

xml
<bpmn:exclusiveGateway id="gw" name="Решение" default="f_reject"/>
<bpmn:sequenceFlow id="f_approve" sourceRef="gw" targetRef="s_approved">
  <bpmn:conditionExpression>${decision == 'approved'}</bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:sequenceFlow id="f_reject" sourceRef="gw" targetRef="s_rejected"/>
ТегЛогикаРазветвлениеСлияние
exclusiveGatewayXORодна веткапродолжает по первому пришедшему токену
parallelGatewayANDвсе ветки, условия игнорируютсяждёт все входящие дуги
inclusiveGatewayORвсе ветки с истинным условиемждёт только реально пройденные ветки

Токены слияния копятся в process_variables['_gateway_tokens'][nodeId]. OR-слияние дополнительно опирается на process_variables['_taken_flows'] — какие дуги были выбраны выше по потоку.

Таймеры и границы

xml
<bpmn:intermediateCatchEvent id="wait_24h" name="Подождать сутки">
  <bpmn:timerEventDefinition>
    <bpmn:timeDuration>PT24H</bpmn:timeDuration>
  </bpmn:timerEventDefinition>
</bpmn:intermediateCatchEvent>
xml
<bpmn:boundaryEvent id="sla" attachedToRef="t_resolve" cancelActivity="true">
  <bpmn:timerEventDefinition>
    <bpmn:timeDuration>PT4H</bpmn:timeDuration>
  </bpmn:timerEventDefinition>
</bpmn:boundaryEvent>

<bpmn:boundaryEvent id="on_error" attachedToRef="s_notify" cancelActivity="true">
  <bpmn:errorEventDefinition/>
</bpmn:boundaryEvent>

Форматы ISO 8601: PT30M, PT2H, P1D, P7D. Срабатывания хранятся в process_timers и разбираются командой process:tasks (планировщик Laravel, ежеминутно) — без запущенного планировщика паузы и SLA не сработают. За errorBoundary доступны переменные _error и _error_node_id.


Ветвление и условия

Условие можно повесить прямо на исходящую дугу любого узла — отдельный шлюз не обязателен:

xml
<bpmn:userTask id="decide" name="Согласование" camunda:formKey="approval" default="f_reject"/>

<bpmn:sequenceFlow id="f_approve" sourceRef="decide" targetRef="s_approved" orbit:branch="approved">
  <bpmn:conditionExpression>${decision == 'approved'}</bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:sequenceFlow id="f_reject" sourceRef="decide" targetRef="s_rejected" orbit:branch="rejected"/>

Отбор дуг (ProcessRuntime::selectFlows):

  1. Берутся все дуги, чьё условие истинно.
  2. Дуга без условия проходит всегда — схема вовсе без условий работает как безусловный веер.
  3. Дуга из атрибута default проходит только когда не прошла ни одна другая.
  4. Не прошло ничего — узел падает с ошибкой.

parallelGateway условия своих дуг игнорирует: AND-разветвление по спецификации уходит во все ветки, а отфильтрованная дуга навсегда подвесила бы парное слияние.

orbit:branch — пометка редактора: она возвращает стрелку на якорь ✔/✘ узла согласования. Движок исполняет conditionExpression, а не её.

Синтаксис выражений

Выражение оборачивается в ${...} или #{...} и считается Symfony ExpressionLanguage. Доступны родные инфиксные операторы: ==, !=, >, <, >=, <=, in, not in, contains, starts with, ends with, and, or, not. Регистрировать под них функции не нужно.

Отрицание строковых операторов пишется скобками — not (x contains 'y'); инфиксного x not contains 'y' в грамматике нет.

Отсутствующая переменная — это null

ProcessRuntime::evaluateCondition подставляет null вместо переменных, которых нет в контексте, и пишет о них в лог. Раньше ExpressionLanguage бросал на них SyntaxError, и всё выражение целиком становилось ложным — «поле не заполнено» не могло быть истинным в принципе, а x != 'a' не срабатывало там, где должно.

Проверки заполненности конструктор разворачивает в сравнения: «не заполнено» → x == null or x == '', «заполнено» → x != null and x != ''.

Переменные процесса

ПеременнаяОткуда
поля записиpayload экземпляра на момент старта
decisionapproved / rejected — исход согласования
decision_reasonкомментарий к решению
approved_count, rejected_count, votes_totalсчётчики multi-instance
out-маппинги callActivityимена из camunda:out
_error, _error_node_idза errorBoundary
_gateway_tokens, _taken_flows, _miслужебные, движок

Переменной approved не существует

Обычная задача (userTask без formKey="approval") завершается статусом completed и переменной решения не пишет вовсе. Условие вида ${approved} вычислится по null и всегда даст ложь — ветка уйдёт в default. Проверяйте ${decision == 'approved'}.


Валидация диаграммы

ProcessGraphValidator и редактор подсвечивают конструкции, которые приведут к тихому зависанию.

Параллельное слияние условных веток → дедлок

parallelGateway-джойн ждёт токен из каждой входящей ветки. Если выше по потоку стоит exclusiveGateway, токен пойдёт только по одной ветке — джойн не соберёт кворум. Внешне это «процесс завис»: active_nodes пуст, недособранный токен лежит в _gateway_tokens.

xml
<!-- ПЛОХО: XOR-развилка, AND-слияние -->
<bpmn:exclusiveGateway id="xor_split"/>
<bpmn:sequenceFlow sourceRef="xor_split" targetRef="task_a">
  <bpmn:conditionExpression>${amount > 1000}</bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:sequenceFlow sourceRef="xor_split" targetRef="task_b"/>
<bpmn:parallelGateway id="and_join"/>
xml
<!-- ХОРОШО: условные ветки сливаются exclusiveGateway -->
<bpmn:exclusiveGateway id="xor_join"/>
<bpmn:sequenceFlow sourceRef="task_a" targetRef="xor_join"/>
<bpmn:sequenceFlow sourceRef="task_b" targetRef="xor_join"/>

Чем разветвили — тем и сливайте. Если ветки условные, но пройти могут несколько, ставьте inclusiveGateway: OR-слияние ждёт только те ветки, которые действительно пошли.

Эксклюзивный шлюз без условий

Нет ни одного conditionExpression на исходящих и не задан default — маршрут не определён; фактическое поведение зависит от порядка веток в XML.

Активность с несколькими выходами

  • у согласования несколько выходов без условий — ошибка;
  • у прочих задач безусловный веер — предупреждение;
  • все ветки условные и нет defaultпредупреждение: если ни одно условие не выполнится, узел упадёт;
  • заведена только одна ветка решения (✔ или ✘) — предупреждение;
  • две стрелки из одного якоря решения — ошибка.

Модели данных

ProcessTemplate

uid                     string — идентификатор шаблона, общий для всех версий
version                 int    — версия; правки сохраняются новой
name, description       string
table_id                int    — FK на sys_tables (NOT NULL)
structure_xml           text   — BPMN XML
compiled                json   — разобранная схема
active                  bool
manual_start_mode       string — disabled | all | roles
manual_start_roles      json   — id ролей (только в режиме roles)
manual_start_conditions json   — {logic, conditions[]}
parallel_mode           string — none | per_record | global
package_id              int    — членство в пакете

ProcessInstance

uid                 string
template_id         int    — FK на process_templates
template_version    int    — версия, по которой идёт экземпляр
status              string — initial | running | completed | failed
table_id            int    — FK на sys_tables
entity_instance_id  int    — id записи
payload             json   — поля записи на момент старта
process_variables   json   — переменные процесса
active_nodes        json   — узлы, ожидающие действия
parent_instance_id  int    — родитель для подпроцессов
parent_node_id      string — узел callActivity в родителе
root_instance_id    int    — корень дерева подпроцессов
last_error          json   — ошибка последнего упавшего узла
started_at, finished_at

ProcessSubscription

template_id, template_uid, template_version
node_id              string — узел, породивший подписку
kind                 string — start | resume
instance_id          int    — только для resume
event                string — record_created | record_updated | field_changed
                              | state_changed | signal
table_id             int
field_name           string — для field_changed / state_changed
expected_value       string — сверяется со значением поля
condition_expression string — доп. условие по данным записи
correlation          string — entity | variable:{имя} (resume)

ProcessTimer

instance_id       int
node_id           string — таймерный узел
attached_node_id  string — attachedToRef границы (null у промежуточных)
task_record_id    int    — задача, чей SLA сторожит граница
task_kind         string — task | approval | slug сущности-задачи
fire_at           datetime
cancel_activity   bool
status            string — pending | fired | cancelled

Задачи

Отдельной таблицы process_tasks больше нет: задача процесса — обычная запись сущности.

ВидСущностьПеременная решения
taskbp_tasksне пишет
approvalbp_approvalsdecision = approved / rejected
произвольнаялюбая сущность с options.task_capabilityпо её модели состояний

TaskKindRegistry собирает дескрипторы видов, TaskBridge переводит узел процесса в запись и обратно. Завершение задачи — PATCH /api/e/{сущность}/{id} со статусом из decision-словаря вида.


Известные особенности

СитуацияПоведение
Ошибка в scriptTaskПробрасывается: ветка ошибки или падение экземпляра
Ошибка отправки в узле «Уведомление»Логируется, процесс продолжается
Некорректный assigneeЗадача создаётся без назначения
Токены parallelGatewayОстаются в process_variables после завершения экземпляра
Невалидный ISO 8601 таймерПауза пропускается, процесс идёт дальше
complete_on_field_Сравнение ==, а не ===
Ни одна дуга не подошлаlast_error, errorBoundary или падение экземпляра
Переменная отсутствует в условииПодставляется null, пишется в лог
${approved} в условииТакой переменной движок не пишет — всегда ложь
Планировщик не запущенТаймерные паузы и SLA не срабатывают
Цепочка событий глубже 5Обрывается ProcessEventBus, пишется в лог

Документация Orbita ITSM