Workflow у Document Approval System

Коли .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.Server\API\Services\WorkflowAPI.cs

Отже, поїхали.

Як ми боролися з Workflow Foundation
Ви підключили WF до проекту, навчилися рухати документ за маршрутом, вказали умови зміни статусу. Про те, як це зробити, написано в статтях, які я навів вище.

Далі починається найцікавіше…



Отримання списку доступних команд для користувача

WF не підтримує Commands і Actors (автор документа, начальник автора, контролер, менеджер).
Це потрібно реалізовувати самостійно. Причому, якщо у версії WWF 4 можна отримати список Bookmarks, то у версії 3.5 цього зробити було не можна і доводилося для кожного стану список команд зберігати окремо.



Процитую автора з окресленої вище статті:
Крім того, до кожної Загальної активності окремо зберігається деякий набір метаданих: привілеї для запуску, типи документів, за якими дозволено запускати активність, Dynamic LINQ — вираз до документа для перевірки можливості запуску та інші.
Для кожній активності окремо потрібно вказати набір метаданих, за яким потім потрібно перевіряти доступ.
Все правильно, ми так само робили.
Один раз це можна зробити, підтримувати це в актуальному стані важко.

Отримання списку вхідних документів

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

Це змусило нас написати фільтр, який робив вибірку документів, доступних для узгодження поточному користувачем в залежності від ролей користувача, його місця в ієрархії підрозділів, атрибутів документа та інших параметрів.



Приклад фільтраСтатуси процесу перераховані в 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} І {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.



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

Історія узгодження… з перерахуваннями майбутніх етапів

Реалізація історії узгодження тривіальна річ. Потрібно зберігати в табличку інформацію, хто, коли і на яку кнопку натиснув. Але проста історія погодження клієнта не влаштовувала.

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



На цьому танці бубном навколо WF нам набридли. Стали думати, як би нам розлучитися з...WF.

До речі, зараз ми вирішуємо цю задачку на раз-два: в нашому продукті є спеціальний режим — Pre-Execution mode. Який дозволяє зробити холостий прогін по маршруту і сформувати майбутні етапи і потенційних узгоджувачів.


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

Дати клієнту дизайнер від WF, зі зрозумілих причин, ми не могли. Не пам'ятаю як, але якось переконали клієнта, що не варто на даному етапі цього робити.



Динамічне додавання станів в схему

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



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

Якщо хтось реалізовував подібний кейс на WF, поділіться прикладом, дуже цікаво на це подивитися.

Підтримка

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

  • Чому користувач не бачить документ, який повинен узгодити?
  • Чому користувач бачить документ, але не може узгодити?
  • Чому користувач погоджує документ, а у нього вилітає помилка?
Це типова ситуація для випадків, де логіка дублюється (частина умов в WF, частина метаданих, частина SQL фільтрі для вхідних).

До цього додалося те, що WF видавав незрозумілі помилки, які однозначно не можна було інтерпретувати. Кілька разів доводилося виїжджати на майданчик до клієнта.

Підведемо підсумки
Якщо ви робите інформаційну систему, де є функціонал узгодження, то з імовірністю 99% ви зіткнетеся з більшістю з перерахованих вище випадків. Реалізовувати це на WF може дозволити собі не кожна компанія. Не кожен замовник за це буде готовий платити.

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



Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.