BPMN-процессы
Orbita исполняет подмножество BPMN 2.0 с расширениями Camunda и собственным namespace orbit. Схема хранится в process_templates.structure_xml; движок разбирает XML в плоские массивы узлов и дуг и исполняет узлы по одному.
Модуль может быть выключен
Функция бизнес-процессов включается оператором платформы — глобально и отдельно по тенантам. При выключенном модуле API процессов возвращает 403/404, старт по событиям не срабатывает, планировщик таймеров пропускает тенант, а разделы процессов скрыты из интерфейса.
Слои
| Класс | Ответственность |
|---|---|
ProcessDefinitionParser | XML → nodes / flows. Единственное место, знающее про XML |
ProcessDefinitionCompiler | старт-подписки шаблона из узлов «По событию» |
ProcessStartService | запуск экземпляра: режим ручного старта, роли, условия, запрет дублей |
ProcessRuntime | advance / executeNode / selectFlows / evaluateCondition, таймеры, завершение задач |
NodeExecutors\* | исполнение конкретного типа узла |
ProcessEventBus | события записи → старт новых экземпляров и пробуждение ждущих |
TaskBridge | задачи процесса ↔ записи сущностей-задач |
TaskKindRegistry | какие сущности считаются задачами |
ProcessGraphValidator | проверки схемы, зеркалящие правила рантайма |
Как работает движок
- Экземпляр создаётся
ProcessStartService— вручную с карточки, действием автоматизацииstart_process, узломcallActivityилиProcessEventBusпо подписке.payloadзаполняется данными записи,process_variablesинициализируются из него. ProcessRuntime::advance()берёт исходящие дуги узла, отбирает их черезselectFlows()и вызываетexecuteNode()для каждой цели.executeNode()отдаёт узел исполнителю изNodeExecutors. Автоматические узлы отрабатывают и сами вызываютadvance();userTaskсоздаёт запись задачи и останавливает ветку.- Завершение задачи = обновление её записи.
TaskBridgeловит это и зовётProcessRuntime::onTaskCompleted(), который пишет переменные решения и продолжает поток. - Ошибка узла уходит в
handleNodeError(): при навешенномerrorBoundaryпоток идёт по ветке ошибки, иначе экземпляр падает сlast_error(и ошибка поднимается в родительскийcallActivity, если он есть).
Заготовка 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. Различаются они атрибутом, а не тегом:
| Элемент редактора | Тег | Признак |
|---|---|---|
| Согласование | userTask | camunda:formKey="approval" |
| Уведомление | serviceTask | camunda:type="notification" |
| Смена статуса | serviceTask | orbit:action="set_state" |
| По событию | startEvent | bpmn:messageEventDefinition + orbit:eventType |
| Ожидание события | intermediateCatchEvent | bpmn:messageEventDefinition |
| Пауза по таймеру | intermediateCatchEvent | bpmn:timerEventDefinition |
| SLA-таймер | boundaryEvent | bpmn:timerEventDefinition + attachedToRef |
| Обработчик ошибки | boundaryEvent | bpmn:errorEventDefinition + attachedToRef |
Запуск экземпляра
Старт по событию записи
ProcessDefinitionCompiler заводит строку в process_subscriptions для каждого startEvent с непустым orbit:eventType. Обычный startEvent без него подписки не даёт — такой шаблон стартует только вручную, автоматизацией или как подпроцесс.
<bpmn:startEvent id="start" name="Заявка создана"
orbit:eventType="record_created">
<bpmn:messageEventDefinition/>
</bpmn:startEvent><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_changed | eventField попало в изменённые поля; 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_mode | disabled (по умолчанию) / all / roles |
manual_start_roles | список id ролей, только в режиме roles |
manual_start_conditions | {logic: and|or, conditions: [...]} по полям записи |
parallel_mode | none / per_record / global |
ProcessStartService применяет их на всех путях запуска. Нарушение parallel_mode бросает DuplicateProcessInstanceException (HTTP 409). Эндпоинт GET /api/sys/process_template/startable отдаёт шаблоны, доступные пользователю на конкретной записи, с blocked_reason у заблокированных.
Типы узлов
scriptTask — PHP-скрипт
Доступны $api (PublicApiHelper), $payload (переменные процесса, по ссылке) и $entityId.
<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 — обновление полей
<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" — смена статуса
<bpmn:serviceTask id="s_state" name="В работу"
orbit:action="set_state"
orbit:stateField="status"
orbit:stateValue="in_work"/>Идёт через действие set_state с проверкой модели состояний. Недопустимый переход бросает ошибку — её ловит errorBoundary узла либо падает экземпляр.
serviceTask + camunda:type="notification" — уведомление
Узел рассылает по списку назначений; каждое назначение — свой канал и свои получатели.
<bpmn:serviceTask id="s_notify" name="Уведомить" camunda:type="notification">
<bpmn:extensionElements>
<camunda:properties>
<camunda:property name="notification_targets"
value="[{"channel":"push","recipients":"${assignee}"}]"/>
<camunda:property name="notification_template" value="task_assigned"/>
</camunda:properties>
</bpmn:extensionElements>
</bpmn:serviceTask>| Свойство | Назначение |
|---|---|
notification_targets | JSON-массив назначений: канал, получатели, параметры канала |
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 — задача и согласование
Приостанавливает ветку и создаёт запись сущности-задачи.
<bpmn:userTask id="t_resolve" name="Решить проблему"
camunda:assignee="${assignee}"/><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:candidateOrgUnitsList | id групп и подразделений через запятую |
task_entity | slug сущности задач; пусто — системная «Задачи» |
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 исполнителей и кворум
<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 — подпроцесс
<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>calledElement — uid вызываемого шаблона. orbit:wait="false" запускает подпроцесс и идёт дальше не дожидаясь. camunda:in / camunda:out переносят переменные внутрь и обратно; ошибка подпроцесса поднимается в родителя.
Шлюзы
<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"/>| Тег | Логика | Разветвление | Слияние |
|---|---|---|---|
exclusiveGateway | XOR | одна ветка | продолжает по первому пришедшему токену |
parallelGateway | AND | все ветки, условия игнорируются | ждёт все входящие дуги |
inclusiveGateway | OR | все ветки с истинным условием | ждёт только реально пройденные ветки |
Токены слияния копятся в process_variables['_gateway_tokens'][nodeId]. OR-слияние дополнительно опирается на process_variables['_taken_flows'] — какие дуги были выбраны выше по потоку.
Таймеры и границы
<bpmn:intermediateCatchEvent id="wait_24h" name="Подождать сутки">
<bpmn:timerEventDefinition>
<bpmn:timeDuration>PT24H</bpmn:timeDuration>
</bpmn:timerEventDefinition>
</bpmn:intermediateCatchEvent><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.
Ветвление и условия
Условие можно повесить прямо на исходящую дугу любого узла — отдельный шлюз не обязателен:
<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):
- Берутся все дуги, чьё условие истинно.
- Дуга без условия проходит всегда — схема вовсе без условий работает как безусловный веер.
- Дуга из атрибута
defaultпроходит только когда не прошла ни одна другая. - Не прошло ничего — узел падает с ошибкой.
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 экземпляра на момент старта |
decision | approved / 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.
<!-- ПЛОХО: 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"/><!-- ХОРОШО: условные ветки сливаются 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_atProcessSubscription
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 больше нет: задача процесса — обычная запись сущности.
| Вид | Сущность | Переменная решения |
|---|---|---|
task | bp_tasks | не пишет |
approval | bp_approvals | decision = 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, пишется в лог |