Есть такая традиция — «велосипеды изобретать». Очень часто при интеграциях с другими информационными системами обращаю внимание на то, какие подходы там используются в части управления процессами согласования или другими действиями над рассматриваемым объектом: где‑то делают только уведомления («вам пришёл документ на согласование»), где‑то для каждого документа отдельный уникальный тип задач, где‑то подключают BPMN, а где‑то не делают вообще ничего.
В начале пути я и сам делал больно пользователям и заставлял их бродить по разным вкладкам, чтобы они выполнили какое‑нибудь действие. «Ну что же вы голубчики... чтобы согласовать смету, надо зайти вот сюда, потому туда и нажать здесь, а если нужно согласовать чертёж, то нужно идти в другое место» — часть реальных разговоров с пользователями. И да, мне стыдно, но это часть опыта, без которого невозможно чему‑либо научиться.
Вступление
Когда использование модуля задач продиктовано самой платформой, в которой ведётся разработка — выглядит всё хорошо, обычно платформы уже предоставляют отличные внутренние инструменты управления процессами (ELMA365, 1C, Enovia и так далее). Но в остальных случаях, из‑за отсутствия опыта, берутся за собственную реализацию, которая оказывается весьма ограниченной, прибитой гвоздями и совсем не расширяемой. Ещё хуже, когда начинают сразу стрелять «по воробьям» из BPMN процессора, например Camunda.
Я как сторонник стандартизации и унификации считаю, что давно придумана простая методология, которая обеспечит покрытие 90% внутренних корпоративных процессов, без внедрения сложных BPMN систем.
Методология
Я не являюсь её автором, я лишь сторонник использования стандартной, можно сказать промышленной методологии и просто хочу поделиться опытом, поскольку при подготовке материалов, в открытых источниках я не видел подробного описания как это работает.
Ингредиенты для приготовления:
Маршрут
Шаг
Задачи + замечания
Всё! Больше ничего не нужно. Эта троица способна обеспечить управление процессом почти любой сложности, за счёт применения различных комбинаций маршрутов в одном процессе.
Абстрактный пример:
Маршрут1: внутреннее согласование комплекта документов | |||
Шаг1: Конструктор | Шаг2: Технолог | Шаг3: Нормоконтроль | Шаг4: Главный инженер |
Задача1: ФИО1 | Задача1: Роль1 Задача2: Роль2 | Задача1: Роль3 | Задача1: ФИО4 |
Задача2: ФИО2 | |||
Задача3: ФИО3 | |||
Надеюсь с примером идея стала более ясной. Маршрут — группа шагов с задачами, назначенными на пользователей (или роли). Маршрут считается завершённым, только после того как будут завершены все его шаги, а шаги завершаются только после завершения задач в шаге. После запуска маршрута, активируется самый первый шаг, следующий шаг запускается только после завершения предыдущего шага. Количество шагов и задач неограниченно, но хорошей практикой является логическое разделение большого процесса на более мелкие маршруты с последовательным (или параллельным) запуском.
Комбинация и запуск маршрутов обеспечивается внутренним бизнес процессом. При завершении маршрута автоматически выполняется соответствующее действие над рассматриваемым объектом, а тот в свою очередь должен правильно принять и обработать результат маршрута. Это означает, что после завершения одного маршрута, рассматриваемый объект может сразу запустить другой маршрут, тем самым обеспечивая гибкий подход к управлению бизнес процессом.
При проектировании, многие совершают ошибку, реализовывая внутри маршрута логику различных проверок над рассматриваемым объектом (например, проверка, что не заполнены какие‑то свойства и так далее). Это в корне неверный подход. Маршрут НЕ должен знать, какая логика реализована внутри рассматриваемого объекта. Все проверки по созданию и запуску маршрута должны осуществляться на стороне рассматриваемого объекта. Рассматривайте маршруты как отдельный изолированный микросервис, который ничего не знает об объектах, а лишь запускает процесс с инструкциями и возвращает результат (завершен успешно или отклонён с замечаниями). Дальнейшие действия над объектом осуществляются внутри жизненного цикла рассматриваемого объекта, принимая на вход результат маршрута.
Жизненный цикл
Маршрут:
Черновик — маршрут создан, включая шаги и задачи в них, и готов к запуску.
Запущен — маршрут запущен и ожидает завершения всех шагов.
Завершён — все шаги маршрута завершены.
Шаг маршрута:
Ожидание — ожидает завершения предыдущего шага (или запуска маршрута, если текущий шаг первый).
В процессе — запущен и ожидает завершения всех задач.
Завершён — все задачи шага завершены.
Задача шага:
Ожидание — задача ожидает запуска шага.
В процессе — задача ожидает действия пользователя (или роли).
Завершена — пользователь завершил выполнение задачи.
Обратите внимание, что у каждого элемента свой жизненный цикл, хоть и с одинаковым/похожим набором статусов. Причём жизненный цикл линейный (только вперёд на один статус) и названия статусов характеризуют исключительно текущее состояние и не отражают результат выполнения. Это важно! Многим может показаться, что например для задачи нужен сложный ветвистый жизненный цикл с вариантами: согласован, отклонён и так далее. Это распространенное заблуждение. Не повторяйте эту ошибку. За результат отвечает отдельное свойство — смотрите дальше.
Свойства
Маршрут:
Идентификатор шаблона маршрута — обязательное свойство, «ссылка» на шаблон маршрута (чтобы знать тип маршрута для отчётов).
Описание — обязательное свойство, отражающее цель маршрута (например «внутреннее согласование» и так далее).
Рассматриваемый объект — обязательное свойство, ссылка на рассматриваемый объект (UUID).
target json snapshot — обязательное свойство, отражающее минимальный срез текущих свойств рассматриваемого объекта, понятный пользователю, чтобы отобразить рассматриваемый объект (в виде ключ: значение) прямо в таблице задач пользователя, не обращаясь к разным источникам.
Результат — обязательное дополнительное свойство, отражающее результат завершения (выполнено, отклонено, проигнорировано, отменено).
Шаг:
Описание — обязательное свойство, отражающее цель шага.
Срок — обязательное свойство, ожидаемая дата завершения шага (для напоминаний и корректирующих мероприятий в случае игнорирования процесса участниками шага). Кстати, если вы сейчас думаете про автосогласование по истечении срока... Передумайте. Обычно компании редко идут на такое.
Ждать всех участников — обязательное свойство, если «да», шаг будет ждать завершения всех задач.
Можно отклонять — обязательное свойство, если «да», во всех задачах шага будет доступно отклонение.
Можно игнорировать — обязательное свойство, если «да», во всех задачах шага будет доступно игнорирование (для случаев когда по каким‑то причинам задача неактуальна для данного исполнителя).
Результат — обязательное дополнительное свойство, отражающее результат завершения (выполнено, отклонено, проигнорировано, отменено).
Задача:
Описание — обязательное свойство, отражающее инструкции для пользователя (например «рассмотреть и согласовать» или «приложить скан» и так далее).
Ответственный — обязательное свойство, пользователь или роль (для роли создаётся только одна задача).
Результат — обязательное дополнительное свойство, отражающее результат завершения (назначен пользователь, выполнено, отклонено, проигнорировано, отменено).
Естественно у каждого элемента должна присутствовать история изменений, включая фиксацию даты/времени старта и завершения, чтобы в будущем можно было оценить статистику и провести корректирующие мероприятия над сотрудниками, «плохо» и долго исполняющими свои обязанности.
Алгоритм работы
Итак, допустим у нас есть преднастроенный маршрут, который был запущен каким‑то процессом (триггером при изменении свойства объекта или ещё как‑то). В момент запуска маршрута происходит активация первого шага маршрута, а активация шага в свою очередь активирует задачи. В результате у пользователя на главной странице появляется список задач на исполнение.
Пример UI задачи:
Маршрут: внутреннее согласование | [Завершить/Согласовать] [Отклонить с замечаниями] если доступно в шаге [Игнорировать/Нет предмета согласования] если доступно в шаге [Взять в работу] если в качестве ответственного роль |
Ответственный: ФИО1 | |
Наименование: проверка расценок | |
Описание: скачать файл, проверить корректность расценок, если всё хорошо, то согласовать, иначе отклонить с замечаниями | |
Рассматриваемый объект: [ссылка] | |
Далее описан базовый алгоритм работы, простой и логичный. При желании конечно можно сильно усложнить подход, добавить дополнительные опции, повторный запуск завершённого маршрута с версионированием, реализовать делегирование и автоматическое переназначение, в случае отсутствия ответственного и так далее, но и без этого базовые настройки вполне применимы для широкого спектра задач.
Взять в работу
Если задача назначена на роль, то ее может взять любой пользователь с этой ролью, и после этого задача становится персональной (недоступна другим).
Согласовано/Завершено
Тут всё просто. В случае успешного выполнения задачи, автоматически проверяется свойство шага — Ждать всех участников и в случае если установлено нет — шаг сразу успешно завершается, а всем остальным задачам проставляет результат Отменён и запускается следующий шаг. И так далее, пока маршрут не дойдёт до последнего шага и не завершится успешно. После чего выполняется соответствующее действие над объектом — approved action. Если Ждать всех участников = да, то шаг ждёт успешного результата остальных задач.
Отклонено с замечаниями
Для отклонения задачи, требуется добавить обязательное замечание (можно несколько). Отклонение имеет высший приоритет над другими результатами шага. Если Ждать всех участников установлено да — шаг ждёт остальных и затем всё равно отклоняет шаг, даже если есть успешные согласования. Все последующие шаги отменяются, а сам маршрут также отклоняется и запускает соответствующее действие над рассматриваемым объектом — rejected action, включая передачу списка замечаний в рассматриваемый объект (по хорошему необходимо сохранить список в самом объекте).
Проигнорировано/Нет предмета согласования
Редкое действие, но иногда бывает полезным. Имеет самый низкий приоритет. Если в шаге есть несколько успешных согласований и несколько игнорирований, то шаг всё равно считается успешным и маршрут переключается на следующий шаг. Если все участники проигнорировали задачи, то все последующие шаги отменяются и маршрут запускает соответствующее действие над объектом — ignored action.
Что если рассматриваемый объект удалён/сменил статус
В этом случае задача, шаг и маршрут «тихо» завершаются через отмену, при любом действии. При желании конечно можно автоматически отменять существующие маршруты... но это уже отдельная история, и лучше делать это асинхронно.
Как применять
Указанная методология позволяет строить и запускать не только классический процесс согласования документации, но и строить работу в интерфейсе исключительно через входящие задачи: пользователь выполняет ровно те действия, которые отображаются у него на главной странице в задачах (откорректировать документ, заполнить и отправить форму, согласовать отпуск и так далее) и не бегает среди сотни вкладок и подвкладок, вспоминая где ему нужно нажать «Согласовано».
Следует учесть, что в таком подходе объём задач будет расти очень быстро и потребуется уделить внимание архивированию в БД «старых» завершённых маршрутов, шагов и задач (например с помощью партицирования).
Минутка ненормального программирования:
Я сторонник, что нет ничего лучше кода, поэтому моя реализация маршрутов для JMatrixPlatform осуществляется непосредственно на java. Все свойства, шаги и задачи динамически вычисляются на основе рассматриваемого объекта, например так выглядит шаблон маршрута InternalApproval:
@JModelPart public class InternalApproval extends JRoute<JSLDocumentInfo> { public static final InternalApproval ROUTE = new InternalApproval(null); protected InternalApproval(Void dummy) { super(dummy); } @Override public String resolveRouteDescription(JContext ctx, JSLDocumentInfo target) { return "Внешнее согласование комплекта документов с заказчиком"; } @Override public List<JStep> resolveSteps(JContext ctx, JSLDocumentInfo target) { return List.of(Step1.STEP, Step2.STEP); } @Override public LinkedHashMap<String, String> resolveMap(JContext ctx, JSLDocumentInfo target) { LinkedHashMap<String, String> map = new LinkedHashMap<>(); map.put("Тип", target.getType().getLabel(ctx) + (target.isEmergency() ? " (Аварийный)" : "")); map.put("Код", target.getCode()); return map; } @JModelPart public static class Step1 extends JStep<JSLDocumentInfo> { public static final Step1 STEP = new Step1(null); protected Step1(Void dummy) { super(dummy); } @Override protected Instant resolveDeadline(JContext ctx, JSLDocumentInfo target) { return Instant.now().plusSeconds(259200).truncatedTo(ChronoUnit.DAYS).plusSeconds(86399);// 3 дня до 23:59:59 } @Override protected boolean resolveRequiresAllApprovals(JContext ctx, JSLDocumentInfo target) { return false; } @Override protected String resolveDescription(JContext ctx, JSLDocumentInfo target) { return "Входной контроль"; } @Override protected boolean resolveCanIgnore(JContext ctx, JSLDocumentInfo target) { return false; } @Override protected boolean resolveCanReject(JContext ctx, JSLDocumentInfo target) { return true; } @Override protected List<JTask> resolveTasks(JContext ctx, JSLDocumentInfo target) { return List.of(Task1.TASK); } } @JModelPart public static class Step2 extends JStep<JSLDocumentInfo> { public static final Step2 STEP = new Step2(null); protected Step2(Void dummy) { super(dummy); } @Override protected Instant resolveDeadline(JContext ctx, JSLDocumentInfo target) { return Instant.now().plusSeconds(259200).truncatedTo(ChronoUnit.DAYS).plusSeconds(86399);// 3 дня до 23:59:59 } @Override protected boolean resolveRequiresAllApprovals(JContext ctx, JSLDocumentInfo target) { return false; } @Override protected String resolveDescription(JContext ctx, JSLDocumentInfo target) { return "Нормоконтроль"; } @Override protected boolean resolveCanIgnore(JContext ctx, JSLDocumentInfo target) { return false; } @Override protected boolean resolveCanReject(JContext ctx, JSLDocumentInfo target) { return true; } @Override protected List<JTask> resolveTasks(JContext ctx, JSLDocumentInfo target) { return List.of(Task2.TASK); } } @JModelPart public static class Task1 extends JTask<JSLDocumentInfo> { public static final Task1 TASK = new Task1(null); protected Task1(Void dummy) { super(dummy); } @Override protected String resolveDescription(JContext ctx, JSLDocumentInfo target) { return "Провести входной контроль комплектности документации"; } @Override protected JApprover resolveApprover(JContext ctx, JSLDocumentInfo target) { return new JRoleApprover(DocInner.ROLE); } } @JModelPart public static class Task2 extends JTask<JSLDocumentInfo> { public static final Task2 TASK = new Task2(null); protected Task2(Void dummy) { super(dummy); } @Override protected String resolveDescription(JContext ctx, JSLDocumentInfo target) { return "Провести нормоконтроль"; } @Override protected JApprover resolveApprover(JContext ctx, JSLDocumentInfo target) { return new JPersonApprover("username1"); } } }
Так запускается маршрут (создаётся экземпляр маршрута в БД):
if (//любое условие...) { InternalApproval.ROUTE.createAndPromote(ctx, object); }
Если статус рассматриваемого объекта наследуется от IJRouteImpact, то появляются @Override методы для обработки результатов маршрута на конкретном статусе:
public interface IJRouteImpact { default void routeApproved(JContext ctx, JSLTaskStepRoute jsl) { // nothing }; default void routeRejected(JContext ctx, JSLTaskStepRoute jsl) { // nothing }; default void routeIgnored(JContext ctx, JSLTaskStepRoute jsl) { // nothing }; default void routeRevoked(JContext ctx, JSLTaskStepRoute jsl) { // nothing }; default void taskApproved(JContext ctx, JSLTaskStepRoute jsl) { // nothing }; default void taskRejected(JContext ctx, JSLTaskStepRoute jsl) { // nothing }; default void taskIgnored(JContext ctx, JSLTaskStepRoute jsl) { // nothing }; default void taskRevoked(JContext ctx, JSLTaskStepRoute jsl) { // nothing }; }
Например:
@JModelPart public static final class Done extends JPolicy.JState implements IJRouteImpact { //... @Override public void routeApproved(JContext ctx, JSLTaskStepRoute jsl) { // действие при успешном завершении маршрута new JDomainObject(jsl.getTarget().getId()).promote(ctx); } @Override public void routeRejected(JContext ctx, JSLTaskStepRoute jsl) { // действие при отклонении } }
Скриншот входящей задачи:

Вывод
Правильно приготовленная система маршрутов открывает новый дивный мир, в котором не придётся каждый раз придумывать, как построить и запустить процесс согласования или сообщить пользователю о необходимости какого‑то действия в системе. Пользователи на главной странице сразу получают простые стандартизированные входящие задачи с чёткими инструкциями по их выполнению.
Единая статусная модель (ожидание, в процессе, завершён) и стандартизированные результаты (завершено успешно, отклонено) дают простую возможность корректно строить отчёты и не мучиться перебирая всевозможные CASE WHEN THEN ELSE, когда для каждого entity “колхозят” отдельный тип задач со свой уникальной логикой (ну потому что так исторически сложилось).
Описание шаблонов маршрутов непосредственно в коде даёт версионирование и динамическую логику — возможность менять состав задач в зависимости хоть от фазы луны, в отличие от no‑code систем, где возможности хоть и обширны, но и весьма ограничены одновременно (отсутствие некоторых API для взаимодействия с данными всё равно приводит к необходимости подключения разработчика).
PS Если дочитали до этого места и думаете «у нас всё сложнее, нам точно нужны особые задачи для каждого документа» — что ж, у меня для вас плохие новости... скорее всего, вы находитесь в стадии, когда процессы управляют вами, а не вы ими.

