Есть такая традиция — «велосипеды изобретать». Очень часто при интеграциях с другими информационными системами обращаю внимание на то, какие подходы там используются в части управления процессами согласования или другими действиями над рассматриваемым объектом: где‑то делают только уведомления («вам пришёл документ на согласование»), где‑то для каждого документа отдельный уникальный тип задач, где‑то подключают BPMN, а где‑то не делают вообще ничего.

В начале пути я и сам делал больно пользователям и заставлял их бродить по разным вкладкам, чтобы они выполнили какое‑нибудь действие. «Ну что же вы голубчики... чтобы согласовать смету, надо зайти вот сюда, потому туда и нажать здесь, а если нужно согласовать чертёж, то нужно идти в другое место» — часть реальных разговоров с пользователями. И да, мне стыдно, но это часть опыта, без которого невозможно чему‑либо научиться.

Вступление

Когда использование модуля задач продиктовано самой платформой, в которой ведётся разработка — выглядит всё хорошо, обычно платформы уже предоставляют отличные внутренние инструменты управления процессами (ELMA365, 1C, Enovia и так далее). Но в остальных случаях, из‑за отсутствия опыта, берутся за собственную реализацию, которая оказывается весьма ограниченной, прибитой гвоздями и совсем не расширяемой. Ещё хуже, когда начинают сразу стрелять «по воробьям» из BPMN процессора, например Camunda.

Я как сторонник стандартизации и унификации считаю, что давно придумана простая методология, которая обеспечит покрытие 90% внутренних корпоративных процессов, без внедрения сложных BPMN систем.

Методология

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

Ингредиенты для приготовления:

  1. Маршрут

  2. Шаг

  3. Задачи + замечания

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

Абстрактный пример:

Маршрут1: внутреннее согласование комплекта документов

Шаг1: Конструктор

Шаг2: Технолог

Шаг3: Нормоконтроль

Шаг4: Главный инженер

Задача1: ФИО1

Задача1: Роль1

Задача2: Роль2

Задача1: Роль3

Задача1: ФИО4

Задача2: ФИО2

Задача3: ФИО3

Надеюсь с примером идея стала более ясной. Маршрут — группа шагов с задачами, назначенными на пользователей (или роли). Маршрут считается завершённым, только после того как будут завершены все его шаги, а шаги завершаются только после завершения задач в шаге. После запуска маршрута, активируется самый первый шаг, следующий шаг запускается только после завершения предыдущего шага. Количество шагов и задач неограниченно, но хорошей практикой является логическое разделение большого процесса на более мелкие маршруты с последовательным (или параллельным) запуском.

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

При проектировании, многие совершают ошибку, реализовывая внутри маршрута логику различных проверок над рассматриваемым объектом (например, проверка, что не заполнены какие‑то свойства и так далее). Это в корне неверный подход. Маршрут НЕ должен знать, какая логика реализована внутри рассматриваемого объекта. Все проверки по созданию и запуску маршрута должны осуществляться на стороне рассматриваемого объекта. Рассматривайте маршруты как отдельный изолированный микросервис, который ничего не знает об объектах, а лишь запускает процесс с инструкциями и возвращает результат (завершен успешно или отклонён с замечаниями). Дальнейшие действия над объектом осуществляются внутри жизненного цикла рассматриваемого объекта, принимая на вход результат маршрута.

Жизненный цикл

Маршрут:

  1. Черновик — маршрут создан, включая шаги и задачи в них, и готов к запуску.

  2. Запущен — маршрут запущен и ожидает завершения всех шагов.

  3. Завершён — все шаги маршрута завершены.

Шаг маршрута:

  1. Ожидание — ожидает завершения предыдущего шага (или запуска маршрута, если текущий шаг первый).

  2. В процессе — запущен и ожидает завершения всех задач.

  3. Завершён — все задачи шага завершены.

Задача шага:

  1. Ожидание — задача ожидает запуска шага.

  2. В процессе — задача ожидает действия пользователя (или роли).

  3. Завершена — пользователь завершил выполнение задачи.

Обратите внимание, что у каждого элемента свой жизненный цикл, хоть и с одинаковым/похожим набором статусов. Причём жизненный цикл линейный (только вперёд на один статус) и названия статусов характеризуют исключительно текущее состояние и не отражают результат выполнения. Это важно! Многим может показаться, что например для задачи нужен сложный ветвистый жизненный цикл с вариантами: согласован, отклонён и так далее. Это распространенное заблуждение. Не повторяйте эту ошибку. За результат отвечает отдельное свойство — смотрите дальше.

Свойства

Маршрут:

  1. Идентификатор шаблона маршрута — обязательное свойство, «ссылка» на шаблон маршрута (чтобы знать тип маршрута для отчётов).

  2. Описание — обязательное свойство, отражающее цель маршрута (например «внутреннее согласование» и так далее).

  3. Рассматриваемый объект — обязательное свойство, ссылка на рассматриваемый объект (UUID).

  4. target json snapshot — обязательное свойство, отражающее минимальный срез текущих свойств рассматриваемого объекта, понятный пользователю, чтобы отобразить рассматриваемый объект (в виде ключ: значение) прямо в таблице задач пользователя, не обращаясь к разным источникам.

  5. Результат — обязательное дополнительное свойство, отражающее результат завершения (выполнено, отклонено, проигнорировано, отменено).

Шаг:

  1. Описание — обязательное свойство, отражающее цель шага.

  2. Срок — обязательное свойство, ожидаемая дата завершения шага (для напоминаний и корректирующих мероприятий в случае игнорирования процесса участниками шага). Кстати, если вы сейчас думаете про автосогласование по истечении срока... Передумайте. Обычно компании редко идут на такое.

  3. Ждать всех участников — обязательное свойство, если «да», шаг будет ждать завершения всех задач.

  4. Можно отклонять — обязательное свойство, если «да», во всех задачах шага будет доступно отклонение.

  5. Можно игнорировать — обязательное свойство, если «да», во всех задачах шага будет доступно игнорирование (для случаев когда по каким‑то причинам задача неактуальна для данного исполнителя).

  6. Результат — обязательное дополнительное свойство, отражающее результат завершения (выполнено, отклонено, проигнорировано, отменено).

Задача:

  1. Описание — обязательное свойство, отражающее инструкции для пользователя (например «рассмотреть и согласовать» или «приложить скан» и так далее).

  2. Ответственный — обязательное свойство, пользователь или роль (для роли создаётся только одна задача).

  3. Результат — обязательное дополнительное свойство, отражающее результат завершения (назначен пользователь, выполнено, отклонено, проигнорировано, отменено).

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

Алгоритм работы

Итак, допустим у нас есть преднастроенный маршрут, который был запущен каким‑то процессом (триггером при изменении свойства объекта или ещё как‑то). В момент запуска маршрута происходит активация первого шага маршрута, а активация шага в свою очередь активирует задачи. В результате у пользователя на главной странице появляется список задач на исполнение.

Пример 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) {
      // действие при отклонении
    }
  }

Скриншот входящей задачи:

UI входящей задачи
UI входящей задачи

Вывод

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

Единая статусная модель (ожидание, в процессе, завершён) и стандартизированные результаты (завершено успешно, отклонено) дают простую возможность корректно строить отчёты и не мучиться перебирая всевозможные CASE WHEN THEN ELSE, когда для каждого entity “колхозят” отдельный тип задач со свой уникальной логикой (ну потому что так исторически сложилось).

Описание шаблонов маршрутов непосредственно в коде даёт версионирование и динамическую логику — возможность менять состав задач в зависимости хоть от фазы луны, в отличие от no‑code систем, где возможности хоть и обширны, но и весьма ограничены одновременно (отсутствие некоторых API для взаимодействия с данными всё равно приводит к необходимости подключения разработчика).

PS Если дочитали до этого места и думаете «у нас всё сложнее, нам точно нужны особые задачи для каждого документа» — что ж, у меня для вас плохие новости... скорее всего, вы находитесь в стадии, когда процессы управляют вами, а не вы ими.