Workflow в Document Approval System

в 7:42, , рубрики: .net, wf, workflow

Когда .NET разработчик слышит слова «В проект нужно добавить workflow», то первым приходит в голову идея взять Windows Workflow Foundation.

В 2010 году мы выбрали WF в качестве движка документооборота.

Аргументы просты:

  • Бесплатно;
  • Встроено в Visual Studio;
  • В интернете много информации об использовании WF.

За полтора года (с августа 2010 по февраль 2012) использования WF мы столкнулись с массой разнообразных проблем при реализации требований клиента. В конечном итоге мы были вынуждены отказаться от Windows Workflow Foundation и сделать свою реализацию State Machine.

В этой статье я расскажу об основных проблемах, с которыми мы сталкивались, и как решали (или не решали).

Введение

На мой взгляд, есть две статьи, которые неплохо описывают применение WF в Document Approval System.
Для WWF 3: «Document approval workflow system»;
Для WWF 4: «Обзор Windows Workflow Foundation на примере построения системы электронного документооборота».

Описывают неплохо, но описывают только вершину айсберга.

Если кратко, в этих статьях рассказывается о том, как:

  • Нарисовать схему;
  • Как двигать документ по маршруту;
  • Как указывать условия движения.

Реализация даже этих простых операций требует весьма существенных трудозатрат и не обходится без костылей и плясок с бубном. Плясали с этим бубном и мы.

В отличие от коллеги из Luxoft, я набрался смелости и выложил нашу реализацию модуля workflow на WWF 3.5 «as is» в публичный доступ.

URL: Budget.Server

Краткая информация о проекте

Проект состоит из двух частей: клиентское WinForms-приложение и серверная часть.
По ссылке опубликованы исходники серверной части.
Серверная часть отвечает за документооборот и интеграцию с внешними системами.

Схемы workflow находятся в проекте Budget2.Workflow (Мы использовали WWF 3.5, но те же проблемы остались в WWF 4).
API для работы с workflow в файле: Budget.ServerAPIServicesWorkflowAPI.cs

Итак, поехали.

Как мы боролись с Workflow Foundation

Вы подключили WF к проекту, научились двигать документ по маршруту, указали условия смены статуса. О том, как это сделать, написано в статьях, которые я привел выше.

Дальше начинается самое интересное…

Workflow в Document Approval System - 1

Получение списка доступных команд для пользователя

WF не поддерживает Commands и Actors (автор документа, начальник автора, контролер, менеджер).
Это нужно реализовывать самостоятельно. Причем, если в версии WWF 4 можно получить список Bookmarks, то в версии 3.5 этого сделать было нельзя и приходилось для каждого состояния список команд хранить отдельно.

Workflow в Document Approval System - 2

Процитирую автора из вышеобозначенной статьи:

Кроме того, к каждой Общей активности отдельно сохраняется некоторый набор метаданных: привилегии для запуска, типы документов, по которым разрешено запускать активность, Dynamic LINQ – выражение к документу для проверки возможности запуска и другие.

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

Получение списка входящих документов

Это требование мы реализовывать уже после того, как схема была реализована в WF.
Проблема была до банальности проста: мы могли определить, может ли пользователь на текущем этапе согласовать конкретный документ, но мы не могли получить список всех пользователей, которые могут согласовать документ на данном этапе. В системе порядка 300-400 пользователей, перебором проблему было не решить.

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

Workflow в Document Approval System - 3

Пример фильтра

Статусы процесса перечислены в enum BillDemandStateEnum.

private string GetFilter()
{
    List<Guid> deputyIds =
        DeputyEmployeeRepository.GetReplaceableEmployeeIdentityIds(DocumentType.BillDemand,EmployeeRepository.CurrentEmployee,
                                                                    true);
    string idsString = StringUtil.GetString(deputyIds);

          
    string opSubfilter =
        string.Format(
            "SELECT dbd.{0} FROM {1} dbd INNER JOIN {2} ON dbd.{0} = {3} INNER JOIN {4} ON {5} = {6} LEFT OUTER JOIN {7} ON dbd.{0} = {8} AND {9} = {10} WHERE {11} IS NULL",
            BillDemandTableBase.SelectColumn_Id,
            BillDemandTableBase.DEFAULT_NAME,
            BillDemandDistributionTableBase.DEFAULT_NAME,
            BillDemandDistributionTableBase.FilterColumn_BillDemandDistribution_BillDemandId,
            DemandTableBase.DEFAULT_NAME,
            BillDemandDistributionTableBase.FilterColumn_BillDemandDistribution_DemandId,
            DemandTableBase.FilterColumn_Demand_Id,
            WorkflowSightingTableBase.DEFAULT_NAME,
            WorkflowSightingTableBase.FilterColumn_WorkflowSighting_EntityId,
            DemandTableBase.FilterColumn_Demand_ExecutorStructId,
            WorkflowSightingTableBase.FilterColumn_WorkflowSighting_ItemId,
            WorkflowSightingTableBase.FilterColumn_WorkflowSighting_Id
            );

    string limitSubfilter =
        string.Format(
            "SELECT {0} FROM {1} WHERE {2} = {3} AND {4} = {5} AND {6} IS NULL AND {7} IN ({8}) ",
            WorkflowSightingTableBase.SelectColumn_Id, WorkflowSightingTableBase.DEFAULT_NAME,
            BillDemandTableBase.FilterColumn_BillDemand_Id,
            WorkflowSightingTableBase.FilterColumn_WorkflowSighting_EntityId,
            WorkflowSightingTableBase.SelectColumn_StateId,
            BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
            WorkflowSightingTableBase.SelectColumn_SightingTime,
            WorkflowSightingTableBase.SelectColumn_SighterId, idsString);

         
    string filter =
        string.Format(
            "({0} IN ({1},{2}) AND {3} IN ({4})) OR ( EXISTS ({5}) )",
            BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
            (int) BillDemandStateEnum.Draft,
            (int) BillDemandStateEnum.PostingAccounting,
            BillDemandTableBase.FilterColumn_BillDemand_AuthorId,
            idsString,
            limitSubfilter);

    if (SecurityHelper.IsPrincipalsInRole(deputyIds, SecurityHelper.ControllerRoleId))
        filter += string.Format(" OR {0} = {1}", BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
                                (int) BillDemandStateEnum.UPKZControllerSighting);

    if (SecurityHelper.IsPrincipalsInRole(deputyIds, SecurityHelper.CuratorRoleId))
        filter += string.Format(" OR {0} = {1}", BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
                                (int) BillDemandStateEnum.UPKZCuratorSighting);

    if (SecurityHelper.IsPrincipalsInRole(deputyIds, SecurityHelper.UPKZHeadRoleId))
        filter += string.Format(" OR {0} = {1}", BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
                                (int) BillDemandStateEnum.UPKZHeadSighting);



    if (SecurityHelper.IsPrincipalsAccountant(deputyIds, BudgetPart))
    {
        if (CommonSettings.CheckAccountingInFilial)
        {
            filter += string.Format(" OR ({0} = {1} AND {2} = '{3}')",
                                    BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
                                    (int) BillDemandStateEnum.InAccounting,
                                    BillDemandTableBase.FilterColumn_BillDemand_FilialId,
                                    EmployeeRepository.CurrentEmployeeFilialId);
        }
        else
        {
            filter += string.Format(" OR ({0} = {1})",
                                    BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
                                    (int)BillDemandStateEnum.InAccounting
                                    );
        }
    }

    List<Guid> deputyDivisionHeads = SecurityHelper.GetPrincipalsDivisionHead(deputyIds, BudgetPart);

    if (deputyDivisionHeads.Count > 0)
    {
        string currentEmployeeChildrenStructs = EmployeeRepository.CurrentEmployeeChildrenStructs.Replace("(", "").Replace(")", "");
        string deputyDevisionHeadString = StringUtil.GetString(deputyDivisionHeads);
        filter +=
            string.Format(
                " OR ({0} = {1} AND EXISTS (SELECT vp.Id FROM [dbo].[vStructDivisionParentsAndThis] vp INNER JOIN {2} e ON vp.ParentId = e.{3} WHERE vp.Id = {4} AND e.{5} IN ({6})))",
                BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
                (int)BillDemandStateEnum.HeadInitiatorSighting,
                EmployeeRepository.DEFAULT_NAME,
                EmployeeRepository.SelectColumn_StructDivisionId,
                BillDemandTableBase.FilterColumn_AuthorStructDivision_Id,
                EmployeeRepository.SelectColumn_SecurityTrusteeId,
                deputyDevisionHeadString
                );

        filter +=
            string.Format(
                " OR ({0} = {1} AND {2} > 0 AND EXISTS ({3} AND {4} IN ({5}) AND dbd.{6} = {7}))",
                BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
                (int) BillDemandStateEnum.LimitManagerSighting,
                BillDemandTableBase.FilterColumn_BillDemand_BudgetPartId, opSubfilter,
                DemandTableBase.FilterColumn_Demand_ExecutorStructId,
                currentEmployeeChildrenStructs,
                BillDemandTableBase.SelectColumn_Id,
                BillDemandTableBase.FilterColumn_BillDemand_Id);
    }

    return filter;
}

На эти фильтры у нас ушло 2 недели.

Версионирование схем

Встроенных механизмом обновления схемы процесса в Windows Workflow Foundation 3.5 нет.
В WF 4 ситуация не изменилась — Version handling in Workflow Foundation 4.

Workflow в Document Approval System - 4

Если процесс запущен, то обновить схему марш просто так не получиться. Для обновления схемы нужно иметь старую схему и немножко потанцевать с бубном. Плясали примерно неделю-две, но сделали более или менее работающий механизм обновления схем. Теперь наш проект регулярно пополнялся DDL с наименованиями Workflow.xxx.dll, где xxx — это номер старой версии.

История согласования… с перечислениями будущих этапов

Реализация истории согласования вещь тривиальная. Нужно сохранять в табличку информацию кто, когда и на какую кнопку нажал. Но простая история согласования клиента не устраивала.

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

Workflow в Document Approval System - 5

На этом танцы бубном вокруг WF нам надоели. Стали думать, как бы нам расстаться с...WF.

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

«Дайте нам дизайнер»

Дать клиенту дизайнер от WF, по понятным причинам, мы не могли. Не помню как, но как-то убедили клиента, что не стоит на данном этапе этого делать.

Workflow в Document Approval System - 6

Динамическое добавление состояний в схему

Через год клиент захотел, чтобы в маршрут документа по некоторым условиям добавлялись новые состояния из специального справочника.

Workflow в Document Approval System - 7

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

Если кто-то реализовывал подобный кейс на WF, поделитесь примером, очень интересно на это посмотреть.

Поддержка

После успешного внедрения развитие системы не остановилось. Новые требования поступали регулярно.
Мы вносили изменения с схему маршрута и после каждого обновления нам приходило баги из серии:

  • Почему пользователь не видит документ, который должен согласовать?
  • Почему пользователь видит документ, но не может согласовать?
  • Почему пользователь согласует документ, а у него ошибка вылетает?

Это типичная ситуация для случаев, где логика дублируется (часть условий в WF, часть в метаданных, часть в SQL фильтре для входящих).

К этому добавилось то, что WF выдавал непонятные ошибки, которые однозначно нельзя было интерпретировать. Несколько раз приходилось выезжать на площадку к клиенту.

Подведем итоги

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

Для себя мы сделали выбор — написали свой движок Workflow Engine .NET и успешно его применяем в своих проектах.
В нем мы учли наш опыт реализации систем класса — Document Approval System.

Workflow в Document Approval System - 8

Автор: DmitryMelnikov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js