image1

© Syntellect 2019


1. Расширения

Расширения – это способ дополнить стандартное поведение системы в соответствии с бизнес-требованиями разрабатываемого решения. Расширения пишутся как классы с кодом на платформе .NET на языке C# или VB.NET. Классы реализуют определённый интерфейс расширения (например, ITileExtension) и могут наследоваться от абстрактного класса (TileExtension) для упрощения реализации.

1.1. Системные требования для разработки расширений

К программному обеспечению компьютера разработчика предъявляются следующие требования:

Точные версии ПО для вашей сборки платформы перечислены в файле Source\readme.txt в папке со сборкой.

Для Visual Studio допустимо использовать бесплатную версию Community Edition.

Для удобства разработки рекомендуем на том же компьютере развернуть и настроить систему Tessa. Для этого можно использовать бесплатный SQL Server 2008 Express Edition (или более поздний), а также встроенный в Windows IIS.

1.2. Сборки с расширениями

Классы расширений располагаются в специальных сборках с клиентскими или серверными расширениями.

Tessa.Extensions.Server.dll

сборка с серверными расширениями, которые содержат код, выполняемый только на сервере в веб-сервисе tessa. Серверные расширения имеют доступ к базе данных через объект IDbScope, который можно получить из IoC контейнера Unity или через свойство context.DbScope для некоторых типов расширений (ICardStoreExtension, ICardDeleteExtension), где context – параметр методов расширений.

Tessa.Extensions.Client.dll

сборка с клиентскими расширениями, которые содержат код, выполняемый только на клиентах TessaClient и TessaAdmin (в последнем расширения могут использоваться для предварительного просмотра типов карточек). Клиентские расширения имеют доступ к текущему контексту через статическое свойство UIContext.Current, которое позволяет получить полную информацию по объектам UI в текущей вкладке, будь то вкладка с карточкой, представление или рабочее место.

Также в этой сборке могут содержаться словари ресурсов WPF ResourceDictionary, модели представления ViewModel для реализации паттерна MVVM, элементы управления, поведения и любые другие классы, связанные с взаимодействием с UI на стороне клиента.
Tessa.Extensions.Shared.dll

сборка с общими вспомогательными классами, используемыми как на клиенте в Tessa.Extensions.Client, так и на сервере в Tessa.Extensions.Server. Ещё в этой сборке можно расположить расширения, задействованные и на клиенте, и на сервере. Примером таких расширений могут послужить универсальные расширения ICardRequestExtension на расчёт дайджеста карточки GetDigest.

Tessa.Extensions.Default.(Server|Client|Shared).dll

сборки со всеми расширениями, обеспечивающими работу типового решения.

Ниже приведены советы по использованию различных видов расширений. Список не является исчерпывающим.

Используйте серверные расширения, если:

  • Требуется выполнить прямое взаимодействие с базой данных, например, прочитать данные из таблицы. Для этого из контейнера Unity следует получить объект IDbScope.

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

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

  • Устанавливаются права на доступ к определённым данным карточки.

Используйте клиентские расширения, если:

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

  • Необходимо проверить бизнес-условие перед тем, как будет отправлен запрос на сервер.

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

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

  • Требуется обратиться к веб-сервису с клиентской стороны.

Клиентские и серверные расширения регистрируются в классах Registrator, которые можно расположить в папках в соответствии с их назначением. У подобных классов должен быть атрибут [Registrator].

Подробное устройство расширений с множеством примеров можно рассмотреть на основе сборок с расширениями типовой конфигурации. Однако, не рекомендуется изменять код этих сборок, т.к. при обновлении на новую версию платформы изменится также и код расширений, который нужно будет обновить. Такими сборками являются:

  • Tessa.Extensions.Default.Server – серверные расширения для типового решения.

  • Tessa.Extensions.Default.Client – клиентские расширения для типового решения.

  • Tessa.Extensions.Default.Shared – общие вспомогательные методы и расширения, которые доступны как на клиенте, так и на сервере.

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

1.3. Установка расширений

После того, как solution с расширениями Tessa.Extensions.sln будет скомпилирован, в папку Bin/Tessa.Extensions.Client попадают сборки, необходимые для клиента (Tessa.Extensions.Client.dll, Tessa.Extensions.Shared.dll и сборки для типового решения), а в папку Bin/Tessa.Extensions.Server – сборки для сервера (Tessa.Extensions.Server.dll, Tessa.Extensions.Shared.dll и сборки для типового решения).

Для установки обновлённых расширений следует:

  1. Собрать solution Tessa.Extensions.sln в конфигурации Release.

  2. Скопировать все файлы из папки Bin/Tessa.Extensions.Server в папку с веб-сервисом tessa.

  3. Скопировать все файлы из папки Bin/Tessa.Extensions.Client в папку с приложениями TessaAdmin и TessaClient.

  4. Если установка выполняется для распространения обновления через Tessa Applications, то следует опубликовать приложения TessaAdmin и TessaClient.

1.4. Цепочки расширений

Расширения выполняются цепочками. Цепочка расширений – это упорядоченная по этапам и по порядковому номеру внутри этапа последовательность вызова одного и того же метода для нескольких расширений одного типа. Например, вызов метода BeforeRequest для расширений ICardNewExtension.

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

Различают следующие этапы в порядке следования:

  1. Initialize – платформенные расширения на инициализацию (подготовку) данных, которые должны выполняться раньше, чем любые расширения, добавляемые разработчиком. Разработчикам не следует использовать этот этап в своих расширениях.

  2. BeforePlatform – пользовательские расширения, которые пишут разработчики и которые должны исполняться перед платформенными расширениями этапа Platform.

  3. Platform – платформенные расширения, исполнение которых не привязано строго к инициализации или финализации цепочки расширений. Разработчики могут писать расширения на этапе BeforePlatform, которые исполняются перед этапом Platform, или же на этапе AfterPlatform, которые исполняются после этапа Platform. Этот этап разработчикам использовать не следует.

  4. AfterPlatform – пользовательские расширения, которые пишут разработчики и которые должны исполняться после платформенных расширений этапа Platform.

  5. Finalize – платформенные расширения на финализацию (завершение обработки) данных, которые должны идти после любых расширений, добавляемых разработчиком. Разработчикам не следует использовать этот этап в своих расширениях.

Разработчикам рекомендуется писать расширения на этапе AfterPlatform, если нет причин выполнить некоторую работу перед платформенными расширениями (например, для заполнения виртуальных секций или файлов, которые в определённом сценарии обрабатываются платформенными расширениями).

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

1.5. Создание расширений

Рассмотрим расширение на импорт:

using Tessa.Cards;
using Tessa.Cards.Extensions;
using Tessa.Cards.Extensions.Templates;
using Tessa.Extensions.Default.Shared.Workflow.KrProcess;
using Unity;

public sealed class CardSatelliteImportExtension : CardStoreExtension
{
    public CardSatelliteImportExtension([Dependency(CardRepositoryNames.ExtendedWithoutTransaction)] ICardRepository extendedRepositoryWithoutTransaction)
    {
        this.extendedRepositoryWithoutTransaction = extendedRepositoryWithoutTransaction;
    }

    private readonly ICardRepository extendedRepositoryWithoutTransaction;

    public override void BeforeCommitTransaction(ICardStoreExtensionContext context)
    {
        Card satellite;
        if (context.CardType == null
            || (satellite = CardSatelliteHelper.TryGetSatelliteCard(context.Request.Card, KrConstants.KrSatelliteInfoKey)) == null)
        {
            return;
        }

        satellite.Version = 0;
        satellite.RemoveAllButChanged(CardStoreMode.Insert, CardStoreMethod.Import);

        var request = new CardStoreRequest { Card = satellite, Method = CardStoreMethod.Import };
        CardStoreResponse response = this.extendedRepositoryWithoutTransaction.Store(request);

        context.ValidationResult.Add(response.ValidationResult);
    }
}

Это расширение ICardStoreExtension, которое наследуется от базового класса CardStoreExtension для упрощения реализации. В конструкторе расширение запрашивает зависимости у IoC контейнера Unity. В классах Registrator разработчик может зарегистрировать любые классы и интерфейсы в контейнере unityContainer.RegisterType<IMyInterface, MyType>(…​), после чего экземпляр расширения получит запрошенные из контейнера объекты.

Переопределение метода BeforeCommitTransaction задаёт, каким образом расширение будет участвовать в цепочке расширений, выполняемой перед коммитом транзакции на карточку. В остальных цепочках расширений этот класс также будет участвовать, но реализации методов BeforeRequest, AfterRequest и пр. по умолчанию пустые, т.е. никаких действий в этих цепочках выполняться не будет.

Расширение должно быть зарегистрировано в контейнере расширений IExtensionContainer, который передаётся методу Register класса Registrator.

using Tessa.Cards;
using Tessa.Cards.Extensions;
using Tessa.Cards.Extensions.Templates;
using Tessa.Extensions;
using Tessa.Roles;
using Unity;
using Unity.Lifetime;

[Registrator]
public sealed class Registrator : RegistratorBase
{
    public override void RegisterUnity()
    {
        this.UnityContainer.RegisterType<CardSatelliteImportExtension>(new ContainerControlledLifetimeManager());
    }

   public override void RegisterExtensions(IExtensionContainer extensionContainer)
    {
        extensionContainer
            .RegisterExtension<ICardStoreExtension, CardSatelliteImportExtension>(x => x
                .WithOrder(ExtensionStage.Platform, 1)
                .WithUnity(this.UnityContainer)
                .WhenCardTypes(RoleHelper.PersonalRoleTypeID)
                .WhenMethod(CardStoreMethod.Import));
    }
}

Здесь регистрируется класс CardSatelliteImportExtension для типа расширения ICardStoreExtension (на сохранение карточки). Расширение будет выполняться на этапе BeforeCommitTransaction с порядковым номером внутри этапа, равным 1. Экземпляр расширения создаётся из контейнера Unity, в котором он сразу регистрируется с указанием, что параметр ICardRepository (API для работы с карточками) в конструкторе расширения нужно получить с определённым именем (чтобы все методы API выполнялись с пользовательскими расширениями и без транзакций, т.к. они используются внутри транзакции на карточку).

Расширения обычно выполняются следующим образом:

  1. Создаётся объект контекста context, передаваемый во все методы расширения.

  2. Строится упорядоченный список всех расширений определённого типа. Для сохранения карточки это тип расширения ICardStoreExtension.

  3. Расширения фильтруются в соответствие с данными, находящимися в объекте context. Это могут быть типы карточек WhenTypes(“Incoming”, “Outgoing”), чтобы расширение выполнялось только для входящих и исходящих документов. Или это метод взаимодействия с API карточек WhenMethod.

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

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

  4. Запрашиваются экземпляры всех расширений в цепочке.

    • По умолчанию расширения создаются конструктором по умолчанию. Это можно явно установить вызовом WithDefaultConstructor().

    • Чтобы расширение создавалось ровно один раз для пула приложений через конструктор по умолчанию, следует использовать вызов WithSingleton<TExtension>(), где TExtension – тип регистрируемого расширения. Для примера выше это WithSingleton<DocumentImportExtension>.

    • Если расширение должно запросить из Unity зависимости, то оно может это сделать через конструктор, а тип расширения регистрируется в Unity и выполняется вызов WithUnity(this.UnityContainer). Будет ли расширение создаваться каждый раз или только один раз зависит от ассоциированного LifetimeManager при регистрации в контейнере. Подробнее см. в документации по API Microsoft Unity.

  5. Выполняется метод расширений, соответствующий цепочке (например, метод BeforeCommitTransaction) для каждого экземпляра расширений в порядке, заданном в цепочке.

  6. Далее могут выполняться методы для других цепочек в порядке их вызова системой. Например, после BeforeCommitTransaction транзакция закрывается, блокировка на карточку освобождается и выполняется цепочка AfterRequest.

  7. Выполняется код очистки для каждого экземпляра расширения в цепочке. Если класс расширения реализует интерфейс IDisposable, то для экземпляра такого класса будет выполнен метод Dispose().

1.6. Типы расширений

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

Тип расширения Назначение Клиент/Сервер Пример

ITileGlobalExtension

Создание плиток в боковых панелях.

Только клиент.

Добавляется плитка "Загрузить из 1С" для типа карточки "Договор" (тип проверяется в делегате Evaluating). При нажатии на плитку выполняется запрос к веб-сервису 1C и к карточке прикладывается xml-файл с данными, загруженными из 1С.

ITileLocalExtension

Изменение плиток в боковых панелях перед открытием или после закрытия вкладки с представлением, рабочим местом или карточкой. Также выполняется при обновлении карточки во вкладке, в т.ч. в процессе сохранения.

Только клиент.

Устанавливается название плитки на сохранение карточки как "Сохранить", если открыта уже существующая карточки, или как "Сохранить новую", если открыта только что созданная карточка.

ITilePanelExtension

Изменение плиток перед открытием или после закрытия боковой панели.

Только клиент.

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

ICardUIExtension

Влияние на UI карточки, скрытие контролов. Отслеживание событий открытия / закрытия вкладки с карточкой.

Только клиент.

В карточке "Договор" ранее зарезервированный номер документа освобождается, если карточка ещё ни разу не была сохранена, а вкладка с карточкой закрывается (что происходит также при штатном закрытии приложения TessaClient).

ICardNewExtension

Создание карточки (обычное или по шаблону).

Клиент и сервер.

Заполняются значения по умолчанию для полей StartDate и EndData в новых строках в секции "Замещения" для карточки "Статическая роль" таким образом, чтобы новые строки соответствовали постоянному замещению (от минимальной возможной даты до максимальной).

ICardStoreExtension

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

Клиент и сервер.

Если завершается задание "Согласование" с вариантом завершения "Согласовать", то установить поле с состоянием карточки на "Согласовано" перед сохранением.

ICardGetExtension

Загрузка карточки при открытии, при сохранении в корзину в процессе удаления, при экспорте на диск.

Клиент и сервер.

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

ICardGetFileVersionsExtension

Запрос на получение списка версий файла из карточки.

Клиент и сервер.

Для виртуального файла "Лист согласования" вернуть две версии: обычную и версию для печати, контент которой формируется другим образом.

ICardGetFileContentExtension

Запрос на получение контента для конкретной версии файла из карточки.

Клиент и сервер.

Сформировать контент виртуального файла "Лист согласования" в виде HTML-документа, отражающего текущее состояние бизнес-процесса.

ICardDeleteExtension

Удаление карточки в корзину или без возможности восстановления.

Клиент и сервер.

При удалении карточки с процессом согласования также удалять карточку-сателлит.

ICardRequestExtension

Универсальные запросы к сервису карточек произвольного назначения.

Клиент и сервер.

Дайджест типа карточки "Документ" определяется как текстовое представление номера документа.

ICardRequestExtension для RequestType = GetDefaultInformingUsers

Запрос к сервису карточек для заполнения списка пользователей в диалоге ознакомления с документом.

Клиент и сервер.

Серверное расширение для получения списка ознакамливаемых по-умолчанию.

ICardMetadataExtension

Динамическое изменение типов карточек и метаинформации (например, добавление колонок).

Клиент (для предпросмотра) и сервер.

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

IMySettingsExtension

Модификация поведения диалога "Мои настройки".

Клиент.

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

IPluginExtension

Расширения, выполняемые по расписанию как плагины Chronos.

Сервер (Chronos).

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

Посмотрим, каким образом происходит открытие карточки и какие виды расширений при этом вызываются.

image3

Если уже открытая карточка сохраняется, то порядок выполнения расширений представлен на рисунке ниже.

image4

1.7. Расширения для операций

Базовые операции с любыми карточками:

  • New – создание пакета карточки как структуры в памяти, при этом не выполняется запросов в базу для самой карточки.

    Карточка обычно заполняется значениями по умолчанию, иногда заполняется информация по текущему сотруднику (например, он прописывается в поле "Автор" у документов), иногда резервируется номер (если он есть). Резервирование номера приводит к запросу в базу, но такой номер виден, как ещё невыделенный, и администратор может его дерезервировать из карточки последовательности. Для всех карточек, у которых есть резервирование номеров, работают правила доступа, где возможностью выполнить запрос New определяет флаг «Создание карточки».

  • Get – загрузка карточки. Это любое открытие карточки: из представления, двойным кликом из контролов "ссылка" и "список", обновление уже открытой карточки и т.п.

  • Store – сохранение карточки, причём как первое сохранение (когда в БД выполняются только запросы INSERT), так и любое последующее (когда в карточке есть какие-либо изменения).

  • Delete – удаление карточки. Обычные пользователи могут удалять только в корзину (представление "Удалённые карточки", из которого их можно восстановить). Администраторы также могут удалять, минуя корзину. Корзина очищается сервисом Chronos, по умолчанию через 30 дней. В Истории действий фиксируются все действия, включая удаление и восстановление карточки.

На каждую из таких операций можно написать расширения, которые выполняются ДО (метод BeforeRequest в классе расширения) или ПОСЛЕ (AfterRequest) самого действия с карточкой.

  • Клиентские расширения толстого клиента. Расширения BeforeRequest выполняются до отправки запроса на сервер, AfterRequest – после отправки.

  • Клиентские расширения легкого клиента. Расширения JavaScript (TypeScript), расширения выполняются аналогично толстому клиенту.

  • Серверные расширения, выполняются и для толстого, и для лёгкого клиентов. Различаются для толстого клиента только расширения на инициализацию (которые выполняются при запуске приложения и что-либо передают с сервера на клиент, например, метаинформацию по карточкам с указанием их полей и настроек контролов, список недоступных типов карточек, чтобы не рисовать для них плитки “создать карточку”, и др.). Серверный BeforeRequest выполняется до платформенной обработки, AfterRequest – после. Платформа "между" BeforeRequest и AfterRequest делает свою типовую работу, например, для запроса Get карточка загружается SELECT-ами, для Store – сохраняется.

Также есть запросы Request, они же "универсальные". Они могут быть привязаны или не привязаны к какой-то карточке, их задача – выполнить какое-то произвольное действие, про которое платформа (ядро) не знает, и делать дополнительно ничего не будет. Например, это запрос "Пересчёт календаря", который вызывается с клиента по кнопке, на этот тип запроса подписано единственное серверное расширение, которое проверяет, что если в объекте сессии сейчас администратор (уровень доступа), то можно выполнить хранимую процедуру для расчёта календаря, и либо ничего не вернуть (успех), либо вернуть сообщения об ошибках. Безопасность таких запросов неуниверсальна и различна для каждого запроса, обычно или ограничивается признаком "администратор" в сессии, или запрос выполняет загрузку карточки на сервере с расширениями, и в этом случае работает расчёт прав на Get-запросы (если карточку можно открыть, то запрос будет выполнен).

1.8. Расширения и безопасность

Какое-либо значение для безопасности имеют только серверные расширения всех пяти видов (New, Get, Store, Delete, Request). Даже если клиентские расширения что-то дополнительно проверяют, чтобы не выполнять лишних запросов на сервер при недостатке прав, в действительности, сервер всё равно выполняет ту же проверку ещё раз.

При взаимодействии через веб-сервис любые запросы на карточки гарантированно выполняются с расширениями, т.е. что платформенные (в Tessa.dll), что типовые (Tessa.Extensions.Default.Server.dll / **.Shared.dll), что проектные расширения, будут выполнять все проверки на безопасность внутри себя. Платформенная обработка по умолчанию (т.е. НЕ расширения, например, загрузка карточки SELECT-ами) никак не учитывает безопасность, но её можно вызвать только с сервера (т.е. из кода другого расширения веб-сервиса или из Chronos).

Расширения реализуют такой интерфейс: ICardXXXExtension, где вместо XXX подставляем название операции - ICardGetExtension, ICardRequestExtension и др. У них есть одноимённые абстрактные классы CardGetExtension, CardRequestExtension и т.п., методы которых не выполняют полезной работы (реализация по умолчанию), и от которых наследуются уже реальные расширения. Есть абстрактный класс CardNewGetExtension, который реализует интерфейсы и для ICardGetExtension, и ICardNewExtension, это просто чтобы легче было писать расширения с очень похожей логикой.

Для проверки прав по правилам доступа есть следующие основные расширения:

  1. KrCheckCanCreateNewExtension

  2. KrCheckPermissionsGetExtension

  3. KrCheckPermissionsStoreExtension

  4. KrCheckCanDeleteCard

В них вызываются методы статического класса KrPermissionsHelper.

1.9. Трассировка цепочки расширений карточек

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

Использование трассировки:

  • значительно упрощает отладку расширений;

  • позволяет найти баги, связанные с порядком выполнения расширений или "нежелательными" расширениями в определённых запросах;

  • помогает проводить профилирование с целью определения наиболее затратных по процессорному времени расширений;

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

Трассировка может выполняться на клиенте и на сервере для следующих типов расширений:

  • ICardNewExtension – создание пакета карточки.

  • ICardStoreExtension – сохранение карточки.

  • ICardStoreTaskExtension – сохранение задания (дополнительная цепочка расширений внутри расширений на сохранение карточки).

  • ICardGetExtension – загрузка карточки.

  • ICardGetFileVersionsExtension – загрузка списка версий для файла карточки.

  • ICardGetFileContentExtension – загрузка контента для версии файла карточки.

  • ICardDeleteExtension – удаление карточки.

  • ICardRequestExtension – универсальное действие с карточкой (запрос Digest’а или загрузка данных из 1С).

Если класс расширения наследуется от одного из абстрактных классов (например, CardNewExtension для интерфейса ICardNewExtension), то реализация методов по умолчанию отключает трассировку этих методов. Поэтому при переопределении методов не следует вызывать реализацию метода из базового класса, например, выполнять base.BeforeRequest(context). В таком случае только переопределённые методы будут показаны в результате трассировки.

Различают следующие режимы трассировки:

  • Off – трассировка отключена (по умолчанию). При этом отсутствуют дополнительные затраты процессорного времени и памяти на выполнение трассировки, поэтому именно такой вариант следует использовать в production-версии приложения.

  • On – включена трассировка всех расширений, выполняемых в цепочке. Такой режим позволяет определить порядок и наличие расширений в цепочках, а также отделить основную цепочку расширений от дополнительной ("вложенной") при небольших вносимых задержках в выполнении запросов к карточкам. Примером дополнительной цепочки расширений может служить загрузка карточки-сателлита, которая производится из расширения, расположенного в основной цепочке загрузки карточки.

  • Measure – включена трассировка всех расширений с подсчётом времени выполнения для каждого расширения. В дополнение к режиму "On", этот режим позволяет определить затраты процессорного времени на каждое из выполняемых расширений ценой значительно возросшего времени выполнения запросов к карточкам (пропорционально количеству расширений).

  • Profile – включена трассировка всех расширений с подсчётом времени выполнения для каждого расширения, но для вывода пользователю сохраняются лишь записи для тех расширений, которые выполнялись бы достаточно большое время (не меньше 5 мс). Если в таком режиме при выполнении действий с карточками не будет выведено сообщений, то каждое расширение было выполнено менее, чем за 5 миллисекунд.
    Этот режим позволяет выполнять профилирование времени выполнения расширений и находить слабое по производительности расширение в цепочке, которое можно было бы оптимизировать. Затраты процессорного времени здесь аналогичны затратам в режиме "Measure". При использовании этого режима рекомендуется перед выполнением запроса перезапускать приложение (если выполняется профилирование клиентских расширений) или пул приложений (если профилируются серверные расширения) для того, чтобы свести на нет оптимизации исполняющей среды CLR и серверного кэша для выполнения однотипных действий.

Режим трассировки отдельно указывается для клиента и для сервера.

Режим трассировки на сервере определяется настройкой CardTracingMode в конфигурационном файле app.json сервиса tessa. В качестве значения настройки может быть использован один из приведённых выше режимов (Off, On, Measure, Profile) без учёта регистра символов. Если настройка не указана, то используется режим по умолчанию Off.

image5

При изменении файла app.json необходимо перезапустить пул приложений IIS после сохранения файла и повторить запрос к серверу (перезапускать клиентское приложение не требуется).

Режим трассировки на клиенте задаётся аналогичной настройкой CardTracingMode в конфигурационном файле TessaClient.exe.config приложения TessaClient. Если настройка не указана, то также используется режим по умолчанию Off. После изменения файла требуется перезапустить приложение TessaClient.

image6

Значение, указанное в конфигурационном файле приложения TessaClient, можно переопределить на время одного запуска приложения, указав необязательный ключ командной строки /CardTrace:

TessaClient.exe /CardTrace:Profile

Ключ и его значение нечувствительны к регистру символов и совмещаются с другими ключами:

TessaClient.exe /cardtrace:profile /u:username /p:password

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

  1. Тип выполненного расширения: ICardGetExtension.

  2. Метод расширения в цепочке: BeforeRequest.

  3. Местоположение расширения: клиентское (или серверное).

  4. Код цепочки: 0x03ae91c6. Уникален в пределах одной и той же цепочки на клиенте или на сервере. Позволяет определить момент начала или окончания дополнительной цепочки расширений. Дополнительная цепочка добавляется в основную цепочку расширением из основной цепочки, сделавшим запрос к сервису карточек. Однако, в пределах одной и той же цепочки код на клиенте и на сервере отличается.

  5. Класс расширения: Tessa.Extensions.Platform.Client.Cards.DecompressCardGetExtension (у этого класса вызывается метод, указанный в п.2). Следует учитывать, что даже если некоторые методы базового класса расширения не переопределяются в дочернем классе, то вызов таких методов всё равно будет выполнен (т.к. метод не выполняет действий по умолчанию, то затрат на выполнение такого метода нет, и время, указанное в п.6, будет равно нулю). Например, если серверное расширение на сохранение карточки ICardStoreExtension переопределяет (override) только метод AfterRequest, то для такого расширения всё равно будут вызваны все методы BeforeRequest, AfterBeginTransaction, BeforeCommitTransaction и AfterRequest, из которых первые 3 будут "пустыми" и выполнятся мгновенно.

  6. Время выполнения для метода расширения в миллисекундах (только для режимов "Measure" и "Profile"): 1 мс.

Режим "Measure"

image7

Режим "Profile"

image8

1.10. Пример. Расширение UI на скрытие элементов управления по условию

Необходимо скрыть контрол при наступлении некоторого булевского условия, которое записывается в поле строковой секции MySection.HideControl. В интерфейсе карточки на это поле может быть повешен контрол CheckBox, тогда в момент клика по нему будет изменено поле. Либо поле можно изменить в коде: card.Sections["MySection"].Fields["HideControl"] = true.

Подпишемся на событие изменения поля, и поменяем видимость контрола с алиасом Control1:

using Tessa.Cards;
using Tessa.Platform.Storage;
using Tessa.UI.Cards;

model.Card.Sections["MySection"].FieldChanged += (s, e) =>
{
    if (e.FieldName == "HideControl")
    {
        // модель карточки model : ICardModel, инициализирована снаружи обработчика
        IControlViewModel control = model.Controls["Control1"];

        bool hideControl = (bool)e.FieldValue;
        control.ControlVisibility = hideControl ? Visibility.Collapsed : Visibility.Visible;

        // необходимо скорректировать отступы других контролов в блоке
        control.Block.Rearrange();
    }
};

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

control.Block.Form.Rearrange();

Аналогичным образом можно скрывать блоки вместе со всеми контролами:

using Tessa.Cards;
using Tessa.Platform.Storage;
using Tessa.UI.Cards;

model.Card.Sections["MySection"].FieldChanged += (s, e) =>
{
    if (e.FieldName == "HideBlock")
    {
        IBlockViewModel block = model.Blocks["Block1"];

        bool hideBlock = (bool)e.FieldValue;
        block.BlockVisibility = hideBlock ? Visibility.Collapsed : Visibility.Visible;

        block.Form.Rearrange();
    }
};

Для того, чтобы пересчёт контролов внутри блоков формы делать вручную, вместо block.Form.Rearrange() нужно вызывать метод RearrangeSelf():

// пересчитываем контролы внутри изменённого блока, а затем пересчитываем форму без пересчёта всех дочерних блоков
block.Rearrange();
block.Form.RearrangeSelf();

Добавление обработчика события FieldChanged должно выполняться в расширении UI на открытие карточки:

using Tessa.Cards;
using Tessa.UI;
using Tessa.UI.Cards;
using Tessa.UI.Cards.Controls;

namespace Tessa.Extensions.Client.UI
{
    public sealed class CollapseControlExtension : CardUIExtension
    {
        public override void Initialized(ICardUIExtensionContext context)
        {
            Card card = context.Card;

            card.Sections["MySection"].FieldChanged += (s, e) =>
            {
                // код обработчика, приведённый выше
                ...
            }
        }
    }
}

Пусть скрытие контрола будет выполняться только для типа карточки с именем MyType. Тогда регистрируем расширение следующим образом:

extensionContainer
    .RegisterExtension<ICardUIExtension, CollapseControlExtension>(x => x
        .WithOrder(ExtensionStage.AfterPlatform)
        .WithSingleton<CollapseControlExtension>()
        .WhenCardTypes("MyType"));

2. Работа с карточками объектов

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

2.1. Взаимодействие клиент-сервер

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

Для создания карточки пользователь нажимает на плитку с указанием типа карточки на правой боковой панели.

image15

При этом к плитке привязана команда вызова API карточек, доступного через интерфейс ICardRepository.

Выполняется вызов вида:
CardNewResponse response = cardRepository.New(new CardNewRequest { CardTypeID = ... });

При этом в запрос CardNewRequest передаётся идентификатор типа CardTypeID создаваемой карточки "Договор". Метод New возвращает ответ на запрос CardNewResponse с созданной карточкой response.Card, при этом на клиенте и на сервере выполняется ряд этапов, на которых могут выполняться написанные разработчиком расширения.

При исполнении метода интерфейса ICardRepository без расширений выполняется ровно один запрос через границу клиент-сервер, который на сервере приводит к нескольким запросам к СУБД, причём они инициируются стандартным API. Пользовательские и платформенные расширения могут изменить это поведение, когда может не выполниться ни одного запроса (если речь о виртуальной карточке, доступной только на клиенте, такой как просмотр удалённой карточки) или, наоборот, может выполниться несколько запросов, в т.ч. к разным веб-сервисам (когда необходимо обратиться к веб-сервису, или же требуется загрузить несколько карточек одновременно, чтобы данные одной карточки использоваться вместо данных другой).

Рассмотрим последовательность выполняемых действий:

  1. Перед отправкой на сервер выполняется цепочка расширений BeforeRequest, причём расширения определяются на клиенте в сборке Tessa.Extensions.Client.dll.

    • Эти расширения имеют доступ к текущему контексту UIContext.Current, посредством которого можно получить доступ к вкладке с карточкой, в том числе к конкретным контролам и другим объектам UI.

    • Если в результате выполнения цепочки расширений был установлен результат запроса в свойстве context.Response, то запрос к серверу не исполняется, и следующим шагом выполняется цепочка клиентских расширений AfterRequest. Такое поведение рекомендуется для взаимодействия с виртуальными карточками, доступными только на клиенте. Такими карточками могут быть карточки из сторонней системы, взаимодействие с которой производится с клиента, а не с сервера. Также это можно использовать для карточек, которые кэшируются на клиенте, и при повторном к ним обращении не загружаются с сервера, а создаются из кэша.

    • Если на этом этапе происходит необработанное исключение или ошибки валидации, то выполняется переход к клиентским расширениям AfterRequest, причём свойство context.RequestIsSuccessful устанавливается равным false.

  2. Выполняется сериализация запроса через границу клиент-сервер в формате BSON. На сервере запрос десериализуется, и выполнение продолжается.

  3. Цепочка расширений BeforeRequest на сервере выполняет подготовку карточки к обработке стандартным API.

    • При этом запрос может изменяться таким образом, чтобы стандартное API обработало карточку как-то иначе. Для создания карточки это не имеет особого смысла, но при сохранении карточки на этом этапе карточка может быть изменена перед сохранением: например, при сохранении карточки в процессе начала согласования в поле "Инициатор" записывается текущий пользователь, если других записей там не было.

    • Эта цепочка расширений так же, как и клиентская цепочка BeforeRequest, может установить результат запроса в свойстве context.Response, за счёт чего стандартное API вызвано не будет, а выполнение перейдёт к серверной цепочке расширений AfterRequest. Это рекомендуется для виртуальных карточек, которые доступны как на сервере, так и на клиенте. Данных таких карточек могут быть заполнены любым способом, например, прямыми запросами к базе данных или обращением к сторонней системе.

    • Если на этом этапе происходит необработанное исключение или ошибки валидации, то выполняется переходит к серверным расширениям AfterRequest, причём свойство context.RequestIsSuccessful устанавливается равным false.

  4. Выполняется стандартное API. При этом всегда формируется ответ на запрос штатными средствами платформы. При создании карточки формируется пустой пакет карточки, у которого поля секций заполнены значениями null или значениями из Default constraint, которые указаны.

    • В цепочке расширений AfterRequest платформенные или пользовательские расширения могут заполнить некоторые из полей, например, зарезервировать номер из последовательности и задать его в полях карточки.

    • Если на этом этапе происходит необработанное исключение или ошибки валидации, то выполняется переходит к серверным расширениям AfterRequest, причём свойство context.RequestIsSuccessful устанавливается равным false.

  5. Цепочка расширений AfterRequest на сервере может реагировать на несколько событий.

    • Оно должно проверить context.RequestIsSuccessful, чтобы убедиться в том, что к моменту начала цепочки не возникало ошибок. Если свойство возвращает false, то ошибки имеются.

    • Ошибки могут быть как в расширениях BeforeRequest, так и в стандартном API, причём собственно ошибку может сгенерировать SQL Server или другая внешняя подсистема (файловая система при сохранении карточки с файлами и др.). При наличии ошибок возможна их дополнительная обработка, например, запись информации об определённых ошибках в специальную таблицу.

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

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

    • Если на этом этапе происходит необработанное исключение или ошибки валидации, то выполнение продолжается и в клиентских расширениях AfterRequest свойство context.RequestIsSuccessful устанавливается равным false.

  6. Выполняется сериализация ответа на запрос через границу клиент-сервер в формате BSON. На клиенте ответ на запрос десериализуется, и выполнение продолжается.

  7. Цепочка расширений AfterRequest на клиенте действует аналогично цепочке AfterRequest на сервере, но она имеет доступ к текущему контексту UIContext.Current для того, чтобы можно было определить, откуда производится действие с карточкой, и в соответствии с этим изменить ответ на запрос. Например, если создаётся карточка, когда открыта другая карточка, то некоторые из полей этой другой карточки могут быть заполнены из данных открытой карточки (это своего вида частичное копирование карточки).

    • Рекомендуется всегда проверять значение context.RequestIsSuccessful в самом начале всех расширений на этом этапе, т.к. при наличии ошибок логику выполнения расширения как правило следует изменить.

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

  • На сервере обращение к ICardRepository исключает этапы, относящиеся к клиентским расширениям, но включает все остальные этапы, обращение к API выглядит точно также.

  • Любые расширения на сервере могут обращаться к СУБД, получая IDbScope из Unity или свойства context.DbScope.

  • На любом этапе выполнения запроса к ICardRepository расширения могут обратиться к методам ICardRepository для взаимодействия с этой или другой карточкой.

  • При обращении внутри транзакции на сервере следует обязательно использовать версию API без транзакций.

На следующей схеме приведён процесс типового запроса к API карточек.

image16

На рисунке изображена карточка договора, в которой зарезервирован номер "Д-00003", указан регистратор как текущий пользователь "Администратор" и заполнена дата регистрации, поля "Автор" и "Дата документа".

image17

2.2. Пакет карточки

Любая карточка представляется в памяти структурой в виде вложенных друг в друга хэш-таблиц и списков, поля которых определяют как системные, так и редактируемые пользователем свойства карточки. Такая структура называется пакетом карточки. При переходе через границу клиент-сервер пакет сериализуется в BSON (бинарная форма JSON). В коде расширений к карточке можно обращаться как к объекту Card, а можно получить внутреннее представление card.GetStorage() и обращаться к объекту Dictionary<string, object>.

Например, рассмотрим пакет в результате создания карточки "Договор".

{
    Created: null,
    CreatedByID: 3db19fa0-228a-497f-873a-0250bf0a4ccb,
    CreatedByName: "Администратор",
    Flags: 0,
    ID: 2d489962-a521-4bce-9f19-f41506147bc5,
    Info: null,
    Modified: null,
    ModifiedByID: 3db19fa0-228a-497f-873a-0250bf0a4ccb,
    ModifiedByName: "Администратор",
    Tasks: null,
    TypeCaption: "Договор",
    TypeID: 335f86a1-d009-012c-8b45-1f43c2382c2d,
    TypeName: "Contract",
    Version: 0,
    Files: [ ],
    Permissions: {
        CardPermissions: 5461,
        FilePermissions: null,
        Sections: null
    },
    Sections: {
        DocumentCommonInfo: {
            Fields: {
                Amount: null,
                AuthorID: 3db19fa0-228a-497f-873a-0250bf0a4ccb,
                AuthorName: "Администратор",
                CurrencyID: null,
                CurrencyName: null,
                DocDate: 10/06/2014 12:11:19 UTC,
                FullNumber: "Д-00003",
                Number: 3L,
                PartnerID: null,
                PartnerName: null,
                RegistrationDate: 10/06/2014 12:11:19 UTC,
                RegistratorID: 3db19fa0-228a-497f-873a-0250bf0a4ccb,
                RegistratorName: "Администратор",
                State: "проект",
                Subject: null
            }
        },
        KrApprovalCommonInfoVirtual: {
            Fields: {
                ApprovedBy: null,
                AuthorComment: null,
                AuthorID: null,
                AuthorName: null,
                CurrentApprovalStageRowID: null,
                Cycle: 0,
                DisapprovedBy: null,
                MainCardId: null,
                StateID: 0,
                StateName: "Проект"
            }
        },
        KrApproversVirtual: {
            .table: 1,
            Rows: null
        },
        KrStagesVirtual: {
            .table: 1,
            Rows: [ ]
        }
    },
    TaskHistory: [ ]
}

Пакет содержит:

  • Системная информация (идентификатор карточки ID, тип карточки с идентификатором TypeID, алиасом TypeName и отображаемым именем TypeName, идентификатор CreatedByID и имя CreatedByName пользователя, создавшего карточку).

  • Данные карточки (секции Sections, строки Rows и поля Fields, редактируемые пользователем или скрытые от пользователя и редактируемые в коде расширений).

  • Разрешения на различные взаимодействия с карточкой Permissions. Система разрешений многоуровневая и позволяет настроить общие разрешения на всю карточку CardPermissions (например, флаг ProhibitModify запрещает редактировать всю карточку), а также разрешения Sections на секции, поля и строки, которые могут переопределить разрешения более высокого уровня.

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

  • Список файлов Files. Файл также содержит как системные поля (идентификатор, размер в байтах, список версий и др.), так и настраиваемые данные карточки, и список разрешений, т.к. так же, как и задание, является особым видом карточки.

  • История заданий TaskHistory. Содержит информацию о завершённых заданиях вместе с их результатами.

  • Дополнительная информация Info. В этом разделе может записываться любая (в т.ч. и структурированная) информация, которая нужна расширениям.

В пакете карточки могут быть следующие особые поля:

  • .FieldName - поле добавлено системой (начинается с точки) и содержит нужную платформе информацию; такие поля не влияют на данные и удаляются при сохранении карточки. Определить, является ли поле системным, можно по его ключу, передав его в хэлпер CardHelper.IsSystemKey(string).

  • .changed - список названий изменённых полей, для строковой секции содержится в CardSection, а для табличной или древовидной - в каждой строке CardRow. Пример: .changed: [ "FullName", "Phone" ]

  • .state - состояние строки CardRowState, содержится в каждой строке CardRow табличной или древовидной секции. Значения: 0 - строка не изменена; 1 - строка изменена; 2 - строка добавлена; 3 - строка удалена.

  • __Field - поле добавлено расширениями (начинается с двух подчеркиваний) и содержит нужную им информацию; такие поля не учитываются платформой, не влияют на данные и удаляются при сохранении карточки. Определить, является ли поле пользовательским, можно по его ключу, передав его в хэлпер CardHelper.IsUserKey(string).

Вы можете посмотреть пакет карточки в интерфейсе Tessa Client, открыв любую карточку и выбрав в левой панели тайл Другие→Структура карточки. В открывшемся диалоге можно просмотреть как полностью пакет карточки (сняв флажок "Карточка при сохранении"), так и пакет, который будет отправлен на сервер, если пользователь нажмет "Сохранить" прямо сейчас (при установленном флажке "Карточка при сохранении").

image47

2.3. Загрузка, сохранение и удаление карточки

Когда пользователь нажимает плитку "Сохранить" или выбирает вариант завершения задания, то производится сохранение карточки. При этом карточка сначала сохраняется запросом repository.Store(…​), а затем загружается запросом repository.Get(…​), если сохранение было успешно.

В запрос на сохранение CardStoreRequest передаётся пакет карточки, а в ответе на запрос CardStoreResponse возвращаются сообщения валидации и некоторая системная информация, например, версия после сохранения.

В запрос на загрузку CardGetRequest передаётся идентификатор загружаемой карточки и тип карточки, а в ответе на запрос CardGetResponse возвращается загруженная карточка, если загрузка успешна, и сообщения валидации, которые могут содержать информацию по ошибкам, если, например, карточка не найдена по указанному идентификатору или у пользователя нет прав на открытие карточки. Указанный в запросе тип карточки не учитывается стандартным API, но используется для фильтрации расширений на загрузку карточки.

Процесс загрузки карточки ничем принципиально не отличается от создания, кроме того, что это другой метод ICardRepository, он принимает другой тип запроса и возвращает другой ответ. Также загруженные карточки сжимаются платформенными расширениями непосредственно перед передачей на клиент, где они разжимаются до того, как управление перейдёт к пользовательским расширениям. В процессе того, как стандартное API выполняет запросы на загрузку карточки, захватывается блокировка на чтение карточки. Возможно любое количество одновременных запросов на чтение карточки, но изменяться или удаляться карточка при этом не будет. Специальные расширения, которые выполнялись бы внутри блокировки, отсутствуют, но в цепочках BeforeRequest или AfterRequest можно захватить блокировку вручную, после чего загрузить какую-либо дополнительную информацию по карточке.

В процессе сохранения есть свои особенности. Они связаны с тем, что на сервере сохранение карточки средствами стандартного API выполняются в транзакции. Для того, чтобы в той же самой транзакции выполнить расширения, используются цепочки расширений AfterBeginTransaction и BeforeCommitTransaction.

  1. Стандартное API на сервере подготавливает запросы на сохранение. Если при этом возникает исключение или ошибка валидации, то выполнение переходит к цепочке расширений AfterRequest.

  2. Открывается транзакция и захватывается блокировка на запись карточки. При этом параллельно никто не может читать или изменять карточку, пока блокировка не будет снята.

  3. Цепочка расширений AfterBeginTransaction выполняется сразу после открытия транзакции. На этот момент стандартное API уже построило SQL-запросы на сохранение, поэтому изменять запрос CardStoreRequest к этому моменту не имеет смысла.

    • Расширения позволяют скорректировать состояние базы данных перед тем, как стандартное API выполнит запросы. Например, если есть карточка-сателлит, на которую ссылается сохраняемая карточка, то такую карточку требуется создать именно в этой цепочке. При этом можно использовать как прямые запросы к БД, так и стандартное API ICardRepository, которое следует запросить из Unity с именем CardRepositoryNames.WithoutTransaction. Такое API не будет выполнять действия с карточками внутри транзакции, поэтому можно будет использовать ту же самую транзакцию, что и в основной карточке.

    • Если при выполнении цепочки возникает необработанное исключение, или в свойство context.ValidationResult записывается ошибка валидации, то транзакция откатывается, блокировка снимается, а выполнение переходит к цепочке серверных расширений AfterRequest.

    • В случае ошибок внутри транзакции на этом или более позднем этапе следует учесть, что произойдёт откат транзакции, т.е. будут отменены все изменения, сделанные в базе данных для соединения, доступного через context.DbScope. Если изменения были сделаны в другом соединении с базой данных или на файловой системе (например, был удалён файл, контент которого хранился на диске), то их потребуется вручную откатить в цепочке расширений AfterRequest. Возможно, эти изменения следует делать сразу в AfterRequest, чтобы они выполнялись только в случае успешного сохранения карточки.

  4. Стандартное API выполняет SQL запросы (взятие блокировки на запись, изменение полей карточки, вставка/удаление строки в коллекционные секции, создание и завершение задач, добавление файлов (без контента)) после расширений BeforeRequest и перед расширениями AfterBeginTransaction.

    • При возникновении исключений на этом этапе транзакция откатывается вместе со всеми изменениями, выполненными в цепочке AfterBeginTransaction, после чего выполнение переходит к AfterRequest.

  5. Цепочка расширений BeforeCommitTransaction выполняется после стандартного API и перед коммитом транзакции. В этой цепочке рекомендуется изменять значения, сохранённые стандартным API в базе данных. Например, поставить дату сохранения "задним числом".

    • Если возникли необработанное исключение или ошибки валидации, то транзакция откатывается и выполнение переходит к цепочке серверных расширений AfterRequest.

  6. Выполняется коммит транзакции. Снимается блокировка на запись.

Если в карточке не выполняется изменений, то при сохранении через стандартное API не захватывается блокировка на запись, не открывается транзакции и в карточке не производится никаких изменений (не меняется, кто и когда последний раз сохранял карточку, и не увеличивается номер её версии). При этом не выполняются цепочки расширений AfterBeginTransaction и BeforeCommitTransaction, т.к. нет транзакции. Чтобы установить необходимость выполнения таких расширений независимо от наличия изменений в карточке, то следует установить в запросе флаг request.ForceTransaction = true. В таком случае транзакция всегда открывается, расширения выполняются, но версия карточки и другая системная информация по-прежнему не будет изменена при отсутствии изменений в карточке.

На рисунке приведена схема выполнения запроса на сохранение карточки. Все расширения на серверной стороне могут обращаться к СУБД, поэтому СУБД не отображена на схеме.

image18

Сохранение карточки возможно в двух сценариях.

  • Первичное сохранение карточки, когда она только что была создана и ещё ни разу не сохранена. При этом карточка добавляется в базу данных SQL-запросами INSERT, а свойство card.StoreMode пакета карточки возвращает CardStoreMode.Insert. Перед передачей запроса на сохранение CardStoreRequest с клиента на сервер выполняется метод RemoveAllButChanged(), который удаляет из пакета карточки ненужные данные, такие как история заданий TaskHistory и разрешения на доступ к полям Permissions.

  • Повторное сохранение (изменение) карточки. Для строковых секций карточки генерируются SQL-запросы UPDATE, а свойство card.StoreMode пакета карточки возвращает CardStoreMode.Update. В этом случае метод RemoveAllButChanged() удаляет всю информацию по карточке, которая не изменяется. Например, это файлы и задания, которые не создаются или удаляются, и не изменены. Или это секции с полями и строками, значения которых остались прежними.

    • При сохранении в серверных расширениях может не быть информации по каким-либо полям, которые хоть и не были изменены, но косвенно влияют на изменённые поля. Например, если изменилась сумма договора, но не изменилась валюта, то на сервере нельзя будет сделать проверку "больше ли сумма договора, чем 10000 USD", т.к. валюта неизвестна. В этом случае возможно два выхода.

    • Серверные расширения могут доверять клиенту, в таком случае клиент при изменении суммы передаёт поле с валютой в Info пакета карточки, или использует синтаксис пользовательских полей __Currency, а серверные расширения уже задействуют информацию от клиента для проверки условия.

    • Другой способ: серверное расширение загружает данные по валюте из базы данных, если валюта не была изменена на клиенте. Если загрузку данных и проверку выполнять в расширениях AfterBeginTransaction или BeforeCommitTransaction, то это гарантирует, что параллельный запрос на сохранение не сможет изменить данные после загрузки, но перед фактическим сохранением карточки.

Удаление карточки – это вызов метода repository.Delete(…​). В запросе CardDeleteRequest указывается идентификатор удаляемой карточки и тип карточки. Тип не учитывается стандартным API, но используется для фильтрации расширений на удаление. В ответе на запрос CardDeleteResponse возвращаются сообщения валидации, которые могут содержать ошибки с указанием того, почему удаление не удалось выполнить.

В запросе на удаление также указывается флаг request.DeletionMode, который определяет способ удаления. Значение WithBackup обозначает, что карточка удаляется вся, кроме файлов, но перед удалением сериализуется и помещается в "корзину", из которой она потом может быть восстановлена. Значение WithoutBackup определяет, что карточка удаляется окончательно и восстановлению не подлежит. Поэтому, если требуется сделать освобождение номера при окончательном удалении карточки, но номер не следует освобождать, если карточка переходит в корзину, то в коде расширения следует проверить, что свойство DeletionMode равно WithoutBackup.

Запросы на удаление выполняются через стандартный API в блокировке на запись и в транзакции, которая может быть расширена любыми расширениями таким же образом, как и расширения на сохранение AfterBeginTransaction и BeforeCommitTransaction.

2.4. Запросы Request

Любые взаимодействия с сервисом карточек, которые не укладываются в операции создания (New), сохранения (Store), загрузки (Get) и удаления (Delete) могут быть представлены в виде универсальных расширений ICardRequestExtension, которые получают некоторый абстрактный запрос CardRequest с указанием типа запроса (RequestType: Guid), а возвращают CardResponse с сообщениями валидации.

В зависимости от указанного RequestType выполняются различные расширения, которые могут быть связаны с любыми действиями. Выполнение запроса с цепочками расширений в таком случае аналогично запросам на создание и загрузку карточки, т.е. есть цепочки BeforeRequest и AfterRequest на клиенте и на сервере.

В платформу входят следующие типы запросов:

  • GetDigest: Возвращает дайджест карточки, т.е. текстовое представление текущего состояния карточки в зависимости от данных в её полях. Дайджест выводится в заголовке вкладки, в истории действий, представлении с удалёнными карточками, в карточках шаблонов и др. Расширения на GetDigest регистрируются и на клиенте, и на сервере, а платформенные расширения гарантирует, что клиентский запрос GetDigest не станет обращаться к серверу, чтобы не было падений производительности из-за частых вычислений дайджеста. Рекомендуется для всех типов карточек определять расширение на расчёт дайджеста, причём для нескольких похожих типов можно определить единственное расширение.

  • DeleteHistory: Позволяет удалить историю действий для заданной карточки. Как правило, этот метод не нуждается в расширении, если только не используются дополнительные (несистемные) таблицы с данными в истории действий.

  • Запросы для последовательностей, которые обеспечивают работу клиентского API ISequenceProvider. Расширять эти запросы не рекомендуется.

Для того, чтобы создать собственный тип запросов Request, достаточно определить тип запроса RequestType как случайный Guid, доступный как статическое поле в сборке Tessa.Extensions.Shared. Расширения на клиенте или сервере подписываются подписываются на этот RequestType.

public static class MyRequestTypes
{
	public static readonly Guid MyType = new Guid("...");
}

public sealed class MyRequestExtension : CardRequestExtension
{
    public override void AfterRequest(ICardRequestExtensionContext context)
    {
        if (context.RequestIsSuccessful)
        {
            ...
        }
    }
}

extensionContainer
    .RegisterExtension<ICardRequestExtension, MyRequestExtension>(x => x
    .WithOrder(ExtensionStage.AfterPlatform)
    .WhenRequestTypes(MyRequestTypes.MyType))

CardResponse response = cardRepository.Request(
    new CardRequest { RequestType = MyRequestTypes.MyType, ... })

Специальные методы выполнения запросов

До сих пор было рассмотрено лишь стандартное взаимодействия с методами API карточек. Для некоторых методов доступно также специальное взаимодействие, которое используется при выполнении прочих действий с карточками в системе. Метод задаётся как значение перечисления в запросах к Card***Request, свойство Method.

Для создания карточки New доступны следующие виды методов:

  • Default – стандартное создание карточки, рассмотренное ранее.

  • Template – карточка создаётся по шаблону.

Для загрузки карточки Get:

  • Default – карточка загружается стандартным способом;

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

  • Export - карточка загружается в режиме административного экспорта.

Для сохранения карточки Store:

  • Default – карточка сохраняется стандартным способом;

  • Restore – карточка создаётся в режиме восстановления, т.е. карточка была удалена, после чего восстанавливается. Версия карточки, файлы и некоторые системные поля будут восстановлены после завершения операции. При установке этого свойства стандартный компонент сохранения не закрывает блокировку на запись.

  • Import - карточка сохраняется в режиме административного импорта.

Для удаления карточки доступен только стандартный метод Default. Для запросов Request не определены методы, т.к. поведение запросов изменяется в зависимости от типа запроса RequestType.

По умолчанию любое расширение регистрируется на обработку метода Default. Чтобы изменить это поведение и зарегистрировать расширение на другой метод или сразу несколько методов, при регистрации используется вызов WhenMethod(…​). Если расширение зарегистрировано на несколько методов, то оно может проверить текущий метод в запросе через свойство context.Request.Method.

Например, следующее расширение регистрируется сразу на несколько методов сохранения.

extensionContainer
    .RegisterExtension<ICardStoreExtension, MyStoreExtension>(x => x
        .WithOrder(ExtensionStage.AfterPlatform, 4)
        .WithSingleton<MyStoreExtension>()
        .WhenMethod(CardStoreMethod.Default,
                    CardStoreMethod.Restore,
                    CardStoreMethod.Import));

3. Ролевая модель

В платформе Tessa роли используется повсеместно:

  • для определения прав

  • в процессах согласования и задач

  • при назначении любых заданий

  • при отправке уведомлений

  • при замещении

  • в представлениях

  • в бизнес-логике и коде

  • …​

В приложении Tessa Client администратор может всегда увидеть роли, существующие в системе.

image48

В платформе существуют следующие типы ролей:

Тип роли Описание

Статическая роль

Привычная всем роль-группа. Ее создает администратор и включает в нее сотрудников.

Сотрудник

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

Подразделение

Любое подразделение является ролью, т.к. является группой. Включает в себя всех сотрудников данного подразделения.

Контекстная роль

Это вычисляемая роль, состав которой зависит от контекста, т.е. от карточки, для которой она вычисляется. Например, в роль "Автор документа" будут входить разные сотрудники для разных карточек - тот, кто является автором данной конкретной карточки.

Все вычисляемые роли рассчитываются сервисом Chronos, который должен быть корректно установлен и настроен.

Динамическая роль

Это тоже вычисляемая роль, но она не зависит от контекста. Она вычисляется с указанной администратором периодичностью и в зависимости от вложенной в нее логики может сильно упроситить процесс внедрения. Пример - роль "Все сотрудники", поставляемая с системой. В нее автоматически попадает любой сотрудник, который есть в справочнике сотрудников.

Метароль

Также вычисляемая роль, но ее генератором является карточка специального типа - "Генератор метаролей" (Tessa Client→Администратор→Прочее→Генераторы метаролей). В отличие от динамической роли, генератор может создавать множество ролей сразу. С типовым решением приходит генератор ролей вида "Подразделение (все)". Он генерирует роли, в которые попадают все сотрудники подразделения и всех дочерних подразделений вплоть до листов. Такие роли мы называем Агрегатные (включающие всех сотрудников подразделения).

Временная роль(роль задания)

Эта роль не видна в приведенном выше представлении. Она используется, например, когда назначается задание на контекстную роль, например, "Инициатор согласования". Система вычисляет эту роль, получает список пользователей в ней и создает временную роль, в которую включает этих людей. Роль существует только пока существует это задание. Аналогичным образом создается временная роль, когда отправляется задание нескольким людям в режиме альтернативного исполнения - система объединяет указанных людей во временную роль.

3.1. Контекстная роль

  • Зависит от карточки.

  • Задаётся через SQL.

SELECT #distinct #top_1 t.ParticipantID, FIO
FROM   participants t WITH(NOLOCK)
WHERE  t.CardID = #context_card_id
  AND  (t.RoleID = #role_id OR t.RoleName = #role_name)
  #and_user_id_is(t.ParticipantID)
image49
Возвращает:
  • Режим списка: список пользователей, входящих в роль (ID и имя пользователя или только ID). Передаются параметры ContextCardID, RoleID, RoleName.

Этот режим используется, например, при отправке задания на эту роль - системе нужны ВСЕ пользователи, которые в нее входят.
  • Бинарный режим: признак вхождения пользователя в роль (да / нет). Передаются параметры ContextCardID, RoleID, RoleName, CurrentUserID.

Этот режим используется, например, при вычислении прав. Пользователь открывает карточку - входит ли он в роль "Автор документа" или нет? Остальные пользователи роли, даже если есть - систему не интересуют.
Формирование запроса:
  • Оператор #context_card_id всегда заменяется на параметр @ContextCardID. Это идентификатор карточки.

  • Оператор #distinct заменяется на distinct в режиме списка и на пустую строку в бинарном режиме.

  • Оператор #top_1 заменяется на пустую строку в режиме списка и на top 1 в бинарном режиме.

  • Оператор #role_id всегда заменяется на параметр @RoleID. Это идентификатор контекстной роли.

  • Оператор #role_name всегда заменяется на параметр @RoleName. Это имя контекстной роли.

  • Оператор #and_user_id_is работает следующим образом:

    • Режим списка: выражение удаляется от #and_user_id_is до закрывающейся круглой скобки.

    • Бинарный режим: выражение заменяется на AND t.ParticipantID = @CurrentUserID

Формирование запроса в бинарном режиме:
  • Указывается @CurrentUserID помимо @ContextCardID, @RoleID и @RoleName.

  • Позволяет системе оптимизировать проверку: входит ли пользователь в роль для карточки (проверяется минимум каждый раз при доступе к КД).

Контекстная роль:
  • Используется при расчете маршрута согласования или исполнения карточки, при направлении уведомлений.

  • Роль "фиктивная": вычисляется каждый раз в момент использования, и именно возвращённым пользователям назначаются задания, направляются уведомления и т.д.

  • Состав роли меняется непредсказуемо. Пример: нельзя назначить на роль задание, не разворачивая роль в список пользователей, т.к. рабочее место (показывающее список заданий для пользователя) должно иметь возможность сделать JOIN к этому списку.

  • Физически хранится в таблице с пустым списком пользователей.

При назначении задания на контекстную роль она заменяется ролью задания. Роль задания имеет другой ID, имя контекстной роли, ссылку на контекстную роль в качестве родительской роли и вычисленный состав роли для карточки, на которую назначено задание. Поле с типом роли в таблице с заданиями указывает, что это контекстная роль, а не роль задания. При завершении задания роль задания удаляется, а в истории завершения указывается, что задание было назначено на роль "UserName (RoleName)", например, "Иванов И.И. (Инициатор)", причём тип роли указывается как контекстная роль.

3.1.1. Примеры ролей

  • Согласующие - возвращает список пользователей, у которых есть задание на согласование.

  • Инициатор согласования(входит в поставку) - возвращает пользователя, являющегося инициатором согласования. Например, в маршруте согласования можно прописать "Согласующий1, затем Согласующий2, затем Инициатор". Тогда в момент, когда Согласующий2 завершит согласование, система посчитает эту роль, возьмёт пользователя-инициатора и отправит ему задание.

3.2. Динамическая роль

image50
  • Задаётся через SQL.

  • Возвращает список пользователей, входящих в роль: ID и имя.

  • Пересчитывается в соответствии с настройками (период пересчёта или cron).

  • Можно использовать без ограничений, т.к. всегда существует физический список пользователей, входящих в роль (в БД). На роль можно назначать права и отправлять задания.

3.2.1. Примеры ролей

  • Ивановы - возвращает список пользователей с фамилией, начинающейся с "Иванов".

    SELECT t.ID, t.FullName
    FROM   PersonalRoles t WITH(NOLOCK)
    WHERE  t.FullName LIKE 'Иванов%'
  • Пользователи, у которых сегодня ДР. Обновляется каждый день, используется при нотификации.

  • Мужчины

  • Женщины

3.3. Статическая роль

image51
  • Состав роли: пользователи, вручную добавленные администратором в роль (IsDeputy = false), и их заместители в настоящий момент (IsDeputy = true).

  • Статические роли связаны иерархически.

  • Информация по замещению редактируется пользователями и хранится в таблице замещений:

    • ID и имя пользователя-заместителя

    • ID и имя замещаемого пользователя

    • ID и название роли, для которой выполняется замещение

    • MinDate - дата начала временного замещения или DateTime.MinValue, если замещение не зависит от даты.

    • MaxDate - дата окончания временного замещения или DateTime.MaxValue, если замещение не зависит от даты.

  • Для замещения система выполняет пересчёт состава роли (периодический и по требованию):

    • Удаляет тех заместителей, чьё время закончилось (не входит в интервал MinDate…​MaxDate) или для кого информация была удалена из таблицы замещений.

    • Добавляет тех заместителей, которые отсутствовали в составе роли, но для которых есть запись в таблице замещений и текущее время попадает в интервал MinDate…​MaxDate.

3.4. Подразделение (департамент)

image52
  • Разновидность статической роли.

  • Состав определяется администратором.

  • Задаёт вхождение пользователей в департаменты.

  • Составляет иерархию, отдельную от обычных статических ролей.

  • Обладает дополнительным набором атрибутов. Пример: глава департамента (ссылка на пользователя).

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

  • Замещение аналогично статической роли.

3.5. Сотрудник (пользователь)

image53
Сотрудник, он же пользователь, это персональная роль. Является разновидостью статической роли для которой может быть задано замещение. У пользователя имеются дополнительные поля, хранящие специфичную для пользователя информацию. Рекомендуется ссылаться на таблицу PersonalRoles, а не на общую таблицу Roles.
  • Разновидность статической роли.

  • Соответствует каждому пользователю.

  • Создаётся системой.

  • По умолчанию в состав роли включён соответствующий пользователь (с тем же ID, то есть ссылка на саму роль).

  • Заместители временно включаются в состав роли (см. статическую роль).

  • Используется для назначения прав и заданий, отправки уведомлений и замещения, связанного с конкретным пользователем. Т.о. всё, что можно сделать с ролью, можно сделать и с пользователем.

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

3.6. Генератор ролей и метароли

Генератор ролей:
  • Не является ролью и хранится отдельно от справочника ролей.

  • Имеет название и настройки (период пересчёта или cron).

  • Позволяет при помощи одного SQL генерировать множество ролей - т.н. метаролей. Метароли создаются с указанием обратной ссылки на генератор.

SQL возвращает метароли и входящих в них пользователей (включая текущих заместителей):

role_id (GUID, INT или VARCHAR)

role_name (string)

user_id (GUID)

user_name (string, необязательное)

1

Мужчины

5

Сидоров Ю.В.

1

Мужчины

6

Васечкин С.Т.

2

Женщины

1

Квочкина А.С.

2

Женщины

2

Пятачкова М.У.

При обработке такого SQL-запроса система:
  1. Создаст в справочнике ролей две новых метароли: Мужчины и Женщины.

  2. Включит в них соответствующих пользователей.

  3. Обновит состав метаролей, которые возвращены запросом, но уже были созданы ранее.

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

  5. Каждому из role_id, заданных произвольно, поставит в соответствие "свой" идентификатор, запомнит соответствие и добавит в справочник ролей метароли со "своими" идентификаторами. Сохранённая информация о связи с role_id позволит обновлять и переименовывать метароли, а также ссылаться на них по ID (который не изменится после очередной генерации).

  6. Система сохраняет результаты запроса во временную таблицу и делает её JOIN со справочником ролей для определения имени пользователя, который будет записан в справочник.

Метароли могут включаться в интерфейс выбора роли из справочника путём их группирования по названию генератора ролей.

Если в качестве user_id указать NULL (например, вследствие LEFT JOIN), то по такой строке не будет создано пользователей, но будет создана метароль. Если в результате выполнения SQL генератора будет возвращена метароль, во всех строках с которой указаны user_id, равные NULL, то метароль будет создана без пользователей.

3.6.1. Агрегатная роль

  • Метароль, в которую входят пользователи исходной роли и всех дочерних ролей вплоть до листов без повторений.

  • Работает для всех иерархических ролей (статических ролей и департаментов).

3.7. Временная роль (роль задания)

  • Создаются для конкретного списка пользователей. Имя роли может быть сгенерировано из имён пользователей в списке.

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

  • Автоматически удаляются системой при удалении / завершении задания и при удалении карточки.

  • В истории завершённого задания вместо ссылки на роль задания указывается ссылка на персональную роль пользователя, завершившего задание.

  • Пользовательские таблицы не должны ссылаться через Foreign Key на роль задания.

  • Роль обычно создаётся в пользовательских расширениях перед созданием задания.

Например, через хэлпер CreateTaskRole:

Guid userID1 = ... ;                  // ID пользователей, которых нужно добавить в роль
Guid userID2 = ... ;
IRoleRepository repository = ... ;    // API ролевой модели
IUnityContainer container = ... ;     // Unity-контейнер на серверной стороне, содержащий объект сессии ISession
CardTask task = ... ;                 // создаваемое задание, которое требуется назначить на роль

// создаём роль задания с двумя заданным пользователями и текущим пользователем
PersonalRole user1 = repository.GetPersonalRole(userID1);
IRoleUser user2 = repository.GetRole(userID2).ToRoleUser();
IRoleUser currentUser = new RoleUser(container.Resolve<ISession>().User);
TaskRole taskRole = RoleHelper.CreateTaskRole(user1, user2, currentUser);

// записываем роль вместе с её составом в базу данных.
repository.Insert(taskRole);

// указываем, что задание назначено на роль
task.RoleID = taskRole.ID;
task.RoleName = taskRole.Name;        // эта строка необязательна, но ускоряет создание задания

// после сохранения карточки с заданием task сервис карточек будет производить управление созданной ролью
// поэтому нельзя повторно использовать роль для другого задания!

3.8. Замещение и временное включение в роль.

В каждой карточки роли есть раздел Заместители (таблица RoleDeputies в схеме), где система хранит информацию о замещениях.

image54

При этом замещение может быть неактивным или только на определенный период. Система периодически пересчитывает состав роли, исходя из информации в разделе "Заместители".

Например, если Иванов замещает Сидорова в роли "Руководители департаментов", начиная с 15 января 2023 года и до 20 января 2023 года, то до наступления этой даты Иванов в состав роли не попадет. Однако, как только указанная дата наступит - система включит Иванова в состав роли "Руководителя департаментов" и удалит его оттуда, когда период замещения закончится.
Все замещения рассчитываются сервисом Chronos, который должен быть корректно установлен и настроен.
Не рекомендуется настраивать замещение непосредственно из карточки роли. В системе есть существенно более удобный механизм - карточка Мои замещения. Администраторы могут настраивать замещения пользователей непосредственно из карточки пользователя.

3.9. Параметр "Скрывать при выборе"

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

image55

4. Представления

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

Вы используете представления:

  • Когда в рабочем месте используете любой реестр. Реестр - это представление.

    image56
  • Когда работаете с любым отчетом. Отчет - это одно или несколько представлений, связанных вместе.

    image57
  • Когда выбираете, скажем, сотрудника в поле с автодополнением, вводя первые буквы его фамилии. Введенный вами текст система передает в специальное представление, которое и возвращает тот результат, который вы видите.

    image58

    Представления создаются, редактируются и отлаживаются в Tessa Admin.

Для представления указывается:

  • Метаинформация. Она определяет его возможности и поведение. Колонки, их порядок, их названия, поисковые параметры, подмножества, сортировки, ссылки и т.д.

  • Шаблон sql-запроса. При помощи специальных расширений языка SQL создается шаблон запроса, при помощи которого система генерирует нужный текст запроса в той или иной ситуации. Инструкции шаблонизатора позволяют гибко формировать текст запроса, достигая любого необходимого уровня оптимизации.

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

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

Представление формируется и выполняется на сервере приложений. Клиентское рабочее место передает только заданные поисковые параметры.

image59

4.1. Формат sql-разметки

Текст представлений формируется на языке SQL при помощи специальных инструкций шаблонизатора.

4.2. Типы операторов

Не блочные операторы:
#keyword

или

#keyword(....)

Блочный оператор допускают вложенные неблочные и блочные операторы. Содержимое внутреннего блока трактуется в зависимости от keyword

Блочные операторы:
#keyword{
...
}

или

#keyword(...){
...
}

Блочный оператор с блоком else. Оператор выбирает первый или второй блок в зависимости от условия. Ключевое слово else не используется.

#keyword(...){
основной блок
...
}
{
else блок
...
}

Между ключевым словом и скобками, а также между открывающей и закрывающей скобками любого типа могут быть whitespace, перевод строки:

#keyword ( ....

)


#keyword

(

)


#keyword { ... }

#keyword

{

...

}

4.3. Операторы

4.3.1. #view

Описывает основные параметры представления.

#view(
	DefaultSortColumn: ColAlias1 [asc|desc] [ColAliasN [asc|desc]],
	DefaultSortDirection: asc,
	Paging: no,
	PageLimit: 22,
	ExportDataPageLimit: 1000,
	RowCountSubset: SubsetAlias,
	MultiSelect:true|false,
	Appearance: ColumnAlias,
	SelectionMode: Row|Cell,
	EnableAutoWidth: true|false,
	QuickSearchParam:ParamAlias,
	GroupingColumn:ColAlias,
	AutoWidthRowLimit:20,
	ConnectionAlias:ConnAlias,
	TreatAsSingleQuery:false)
  • DefaultSortColumn - алиасы столбцов в запросе (столбцы должны быть определены через #column), по которым происходит сортировка по умолчанию. Что именно будет писать в запрос #order_by определяется из параметра SortBy этих столбцов. После имени столбца может быть указан порядок сортировки asc, desc. Столбцы и модификаторы сортировки если они указаны должны разделятся пробелом.

  • DefaultSortDirection - asc,desc - Направление сортировки по умолчанию. При этом desc - инвертирует текущий порядок сортировки указанный для столбца.

  • Paging

    • always - пейджинг обязателен всегда.

    • optional - пейджинг может быть отключен (инженером при настройке рабочего места).

    • no - представление не поддерживает пейджинг.

  • PageLimit - размер страницы для данного представления. Имеет смысл, только если представление поддерживает пейджинг. Необязательное, по умолчанию = 20.

  • ExportDataPageLimit - размер страницы для выгрузки. Когда пользователь в клиенте выгружает все данные представления, система получает их на клиент постранично. Размер страницы определяется этим параметром. Это позволяет разработчику управлять нагрузкой на память клиента, каналы и сервер БД, если представление несложное с небольшим количеством столбцов и относительно небольшим количеством строк, можно написать 100 000 000 и все данные будут выгружены за один заход. В любом случае в данном параметре не имеет смысла ставить маленькие значения - выбирайте, начиная от 1000 и более. Необязательное, по умолчанию 1000.

  • RowCountSubset - псевдоним представления, используемого для расчета количества строк в представлении. Для подсчета количества элементов в программном запросе TessaViewRequest к представлению необходимо указать CalculateRowCounting = true. Если псевдоним не задан, или представление отображается не в режиме постраничного вывода, то для расчета количества строк используется число строк возвращенное запросом представления(ITessaResult.Rows.Count). Подмножество заданное в данном параметре является системным и становится не доступным для выбора пользователю в TessaClient и настройки его отображения через TessaAdmin.

  • MultiSelect – признак возможности выделения нескольких строк в представлении.

    • True –возможно выделить несколько строк.

    • False – возможно выделить одну строку (режим по умолчанию).

  • Appearance - алиас колонки в запросе, в которой описано оформление для текущей строки представления.

  • EnableAutoWidth - признак автоматического расчета ширины столбцов представления. Опциональный.

  • SelectionMode - режим выделения. Опциональный.

    • Row - режим выделения строки.

    • Cell - режим выделения ячейки.

  • QuickSearchParam - алиас параметра, в который передается введенный в строку быстрого поиска текст. Если не указан, то строка быстрого поиска для представления не отображается.

    image72
  • GroupingColumn - псевдоним столбца для группировки строк таблицы по умолчанию. Необязательный.

  • AutoWidthRowLimit - Количество строк в результате выполнения представления, допускающее автоматический расчет ширины столбцов таблицы. Необязательное, если значение не указано явно в метаданных будет использовано значение из PageLimit.

  • ConnectionAlias - алиас строки подключения (из конфигурационного файла веб сервиса app.json) к БД, на которой будет выполняться представление вместо дефолтной базы. В конфигурационном файле можно указать подключение к любой СУБД. Если, например, основная база - MSSQL, а подключение к базе Postgres, то запрос генерируется по правилам Postgres; если же база какая-то другая (например, Oracle), то по умолчанию используются правила генерации для MSSQL.

    С помощью данного параметра можно прописать подключение к другой базе, в том числе не к базе Tessa, а, например, к какой-то другой информационнной системе.

  • TreatAsSingleQuery - true - если надо генерировать хранимую процедуру (как в представлениях Postgres); false - если достаточно сразу выполнить запрос (как в MSSQL). Если не указан - false.

4.3.2. #param

Декларирует параметр поиска, на текст запроса не влияет. Внутри могут находиться операторы #autocomplete, #dropdown, #allowed_views.

#param(
  Alias: EmployeeID,
  Caption: $Views_Performer,
  Hidden: false,
  Type: uniqueidentifier,
  Multiple: true,
  RefSection: Performers,
  ConvertToUtc: true,
  AllowedOperands: Equality NonEquality,
  DisallowedOperands: Between Contains,
  HideAutoCompleteButton: false
)
{
  #autocomplete(View: ViewAlias, Param: ParamAlias, PopupColumns: ColumnIndexes, RefPrefix: reference_prefix)
  #source_views(ViewAlias1, ViewAlias2, ...)
  #dropdown(View: ViewAlias, PopupColumns: ColumnIndexes, RefPrefix: reference_prefix)
}
  • Alias - Алиас параметра (должен быть уникальным).

  • Caption - Текстовая метка для интерфейса поисковой формы.

  • Hidden - true, false - признак скрытого параметра. Скрытые параметры используются системой и не видны в поисковой форме. Необязательный. Если не указан - параметр видимый.

  • Type - Тип входящего значение для параметра (в терминах MS SQL Server).

  • Multiple - Может ли параметр быть задан несколько раз. Например, при поиске по сотруднику, можно указать нескольких сотрудников - в результат выборки попадет любой из них. Необязательный. Если не указан - параметр допускает множественный выбор.

  • RefSection - Алиас секции (по факту название таблицы), на которую ссылается данный параметр. Необязателен. Если указан, то система может:

    • Автоматически подобрать для параметра на форме поиска - источники данных, которые предоставляют ссылки на данный же тип секции (см. |#reference|)

    • Если где-то в UI фигурируют данные, являющиеся ссылкой на данную секцию (колонка во представлении или контрол на форме карточки), то система может сама подобрать представления, получающие на входе параметр данного типа и показать их в контекстном меню колонки или контрола. Например, в представлении по входящим есть колонка "Контрагент". Правый клик по колонке покажет, что в системе есть представление "Все договора с контрагентом" (у которого есть параметр со ссылкой на контрагента - partner) и позволит сразу же вызвать это представление с фильтрацией по данному параметру.

  • ConvertToUtc - true, false - признак необходимости конвертации значения параметра в UTC для типов datatime и datatime 2. Необязательный, однако если его не задать для параметра, то при выводе значения в "баллончике фильтрации" дата из UTC будет конвертироваться в локальную, из-за чего она может "съехать" на сутки назад. Если не указан - true.

  • AllowedOperands – Список разрешенных типов фильтрации по данному параметру. Может иметь следующие значения: Between, Contains, StartWith, EndWith, Equality, NonEquality, GreatOrEquals, GreatThan, LessOrEquals, LessThan, IsNull, IsNotNull, IsTrue, IsFalse. Можно указать несколько значений разделяя их пробелом. Необязательный.

  • DisallowedOperands - Список запрещенных типов фильтрации по данному параметру. Может иметь следующие значения: Between, Contains, StartWith, EndWith, Equality, NonEquality, GreatOrEquals, GreatThan, LessOrEquals, LessThan, IsNull, IsNotNull, IsTrue, IsFalse. Можно указать несколько значений разделяя их пробелом. Необязательный.

  • HideAutoCompleteButton - true, false - признак скрытия кнопки выбора значения из диалогового окна. Необязательный. Если не указан - false.

Вложенные операторы #autocomplete#dropdown. Используются для настройки элемента управления для выбора значения для параметра. Элемент управления представляет из себя автокомплит с кнопкой …​.

#autocomplete - описывает представление, которое нужно использовать для автокомплита.

  • Может быть необязательным. По набору текста выполняется определенное представление View: Alias, набранный текст передается в заданный параметр представления Param: ParamAlias, результат выборки представления отображается в качестве таблички автокомплита.

  • По выбору одной из строки, система выбирает из этого представления референс, совпадающий по типу с RefSection параметра. Если таких референсов несколько, система выбирает первый из них.

  • Если #autocomplete не указан, то он не работает.

  • В параметре PopupColumns возможно указать список индексов колонок представления которые будут отображены в виде списка, индексация начинается с 0.

  • RefPrefix: reference_prefix позволяет указать, reference с каким именно префиксом нужно выбирать из представления, указанного в View. Выбирается первый референс, у которого в свойстве ColPrefix указано то же, что и в RefPrefix. Если не указан, то система выбирает ссылку из представления, ориентируясь на #param(RefSection: …​) - т.е. выбирает первую ссылку с таким же RefSection.

#dropdown - описывает представление, которое нужно использовать для вывода выпадающего списка в элементе управления автокомплитом.

  • Может быть необязательным.

  • В виде списка будут отображены значения из представления View: Alias, результат выборки представления отображается в качестве таблички автокомплита.

  • По выбору одной из строки, система выбирает из этого представления референс, совпадающий по типу с RefSection параметра. Если таких референсов несколько, система выбирает первый из них.

  • Если #dropdown не указан, то он не работает.

  • В параметре PopupColumns возможно указать список индексов колонок представления которые будут отображены в виде списка, индексация начинается с 0.

  • RefPrefix: reference_prefix работает аналогично #autocomplete(RefPrefix: …​) и определяет, какой именно референс будет выбран из строки представления, которую выбрал пользователь в dropdown списке.

4.3.3. #subset

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

Подмножества в работе можно посмотреть на примере представления "Мои задания" в типовой конфигурации.

image60
#subset(
    Alias: Employees,
    Caption: Задействованные сотрудники,
    Kind: List,
    CaptionColumn: SqlColAlias,
    RefColumn: SqlColAlias,
    RefParam: ParamAlias,
    TreeRefParam: TreeParamAlias,
    TreeHasChildrenColumn: SqlColAlias,
    CountColumn: SqlColAlias,
    HideZeroCount: true
)
  • Alias - Уникальный алиас Подмножества.

  • Caption - Читабельное название Подмножества.

  • Kind - (List, Tree) Вид Подмножества - плоский список или дерево. Не обязательный, по умолчанию плоский список.

  • CaptionColumn - Алиас колонки из запроса, которая будет использована в качестве названия для UI.

  • RefColumn - Алиас колонки из запроса, которая будет использоваться как значение входящего параметра для фильтрации представления. Важно учитывать, что колонка может содержать null.

  • RefParam - Алиас параметра из текущего представления, в который будет передаваться референсное значение из RefColumn.

  • TreeRefParam - Алиас параметра из текущего представления, который будет использоваться для получения узлов дерева с определенным родителем. Для получения верхнего уровня, в параметр передается NULL, для получения дочерних узлов - в параметр передается значение из RefColumn, которое трактуется как идентификатор текущего узла дерева. Параметр обязателен при Kind: Tree.

  • TreeHasChildrenColumn - Алиас колонки из представления, которая должна содержать значение типа bit, которое трактуется как признак наличия дочерних узлов. Если значение = 1, система показывает плюсик для разворачивания элемента, если 0 - не показывает. Необязательное, если не задано, то система будет показывать плюсики у всех элементов дерева до первой попытки их развернуть, когда выяснится, есть или нет на самом деле у него дочерние элементы. Параметр обязателен при Kind: Tree.

  • CountColumn - Алиас колонки из запроса, которая должна содержать число и трактуется как количество элементов с соответствующим значением референсной колонки во представлении. Необязательный параметр.

  • HideZeroCount - Необязательный, по умолчанию false. Указывать, скрывать ли нулевые значение счетчиков, т.е. не отображать 0. Имеет смысл, только если задан CountColumn.

4.3.4. #column

Описывает одну из колонок в результате выполнения представления. Необязательная директива.

#column(Alias: ColumnAlias,
		Caption: Заголовок,
		Hidden: false,
		SortBy: t.Column1 [asc|desc] [t.ColumnN [asc|desc]],
		Localizable: true,
		MaxLength: 150,
		ConvertToLocal: true,
		Appearance: ColumnAlias|AppearanceAlias,
		DisableGrouping:false)
  • Alias - Алиас должен совпадать с именем колонки в Select.

  • Caption - Читабельное название для колонки. Может быть необязательным, в этом случае название колонки в гриде будет равно алиасу.

  • Hidden (true, false) - Скрывать ли колонку во представлении. Необязательный, по умолчанию false.

  • SortBy - Выражение для сортировки\группировки вида table.Column. Может содержать список столбцов и модификаторов направления сортировки asc, desc разделенные пробелами.

  • Localizable (true|false) - Текст в данной ячейке может содержать константы локализации. Они будут заменены на значения.

  • MaxLength - Текст в данной ячейке будет обрезан до указанного количества символов. Если текст в ячейке обрезается, то в конце добавляется многоточие, а при наведении мыши на данную ячейку появится тултип с полным текстом ячейки. При выгрузке в csv, html, однако - будет выгружаться полный необрезанный текст.

  • ConvertToLocal (true|false) - Для колонки типа datetime определяет, нужно ли конвертировать ее значение в локальное время из UTC. Конвертация производится на клиенте. Необязательный, по умолчанию true.

  • Appearance - Алиас колонки запроса с описанием оформления для текущей колонки или Алиас заданного в метаинформации оформления для текущей колонки.

  • DisableGrouping (true, false) - Запрещать ли группировку строк таблицы по данному столбцу. Необязательный, по умолчанию false.

4.3.5. #reference

Описывает ссылочную колонку, а точнее целиком ссылку. Одна строка представления может предоставлять несколько разных ссылок и даже несколько ссылок одного типа. Например, ссылку на исполнителя и контролера - обе будут иметь RefSection: Users.

Все поля, относящиеся к данной ссылке обязаны иметь один и тот же префикс. Например, строка представления предоставляет две ссылки - на Автора и Исполнителя. Для каждого из них хранится ID и полное ФИО - две колонки. Во представлении будут четыре колонки AuthorId, AuthorFullName, PermormerId, PermormerFullName.

Название полей, идущие после ссылки, будут использоваться системой для маппинга на реальные физические поля карточки. Например, в некой карточке есть ссылка на сотрудника, автора. В схеме ссылочное поле Author и в нем два физических поля AuthorId, AuthorFullName. Пользователь из некоего представления выбирает строчку, содержащую ссылку на сотрудника с префиксом Pref: PrefId, PrefFullName. Далее, для заполнения полей в карточке, система возьмет и в обоих случаях вычтет префикс из имени. Оставшееся будет использовано для маппинга - данные из какой колонки (вычтя префикс Pref, у нас остается Id, FullName) в какое поле карточки (вычтя название ссылочной колонки Author, остается тоже Id, FullName) записать.

#reference(
    ColPrefix: Author,
    RefSection: Empoyee,
    DisplayValueColumn: AuthorFullName,
    IsCard: true,
    OpenOnDoubleClick: true)
  • ColPrefix - Обязательное. Префикс, по которому система поймет, какие поля представления относятся к этой ссылке.

  • RefSection - Обязательное. Алиас секции (по факту таблицы), на которую ссылаемся. Это может быть секция карточки, а может быть перечислением через пробел имен секций(RefSection: Alias1 Alias2 AliasN). Секция может быть виртуальной и даже несуществующей в схеме.

  • DisplayValueColumn - Необязательное. Тут указывается алиас колонки, в которой лежит строковое название для ссылки. Если оно не задано, система сама попробует такую колонку найти, когда ей нужно строковое представление. Например, пройдет по всем колонкам этой ссылки и посмотрит тип у полей, выбрав первое же поле со строковым типом. Например, если ссылка на сотрудника, то здесь будет лежать название колонки, в которой лежит значение вида "Фамилия И.О.". Может использоваться для контекстного меню, а также для открытия других представлений, у которых входящим параметром является ссылка того же типа - для отображаемого значения в контроле выбора значения параметра.

  • IsCard - (true, false) Необязательное. Является ли эта ссылка ссылкой на карточку. Если является, то в контекстном меню строки представления можно эту карточку открыть. Одна строка представления может предоставлять несколько ссылок, при этом некоторые или все могут быть ссылками на карточку, при этом одна из них открывается по дабл клику, а остальные через контекстное меню или возможно, имеет смысл сделать открытие по дабл-клику именно на этой колонке (на колонке DisplayValueColumn, если она видима). Колонка с ключом ссылки почти наверняка будет всегда скрыта.

  • OpenOnDoubleClick - (true, false) Необязательное. По умолчанию false. Если true, то именно эту ссылку нужно открывать по умолчанию, при этом это должна быть карточка, т.е. IsCard: true. 

4.3.6. #appearance и кастомизированное оформление строки и ячейки представления

В Tessa вы можете оформить строки или ячейки представления различными стилям, задать разный фон, шрифт, цвета и т.д. для разных строк или колонок. Оформление задается в виде строки вида.

#appearance(Alias: AppearanceAlias, Foreground: Black, Background: Transparent, FontFamily: font, FontFamilyUri: fontUri, FontSize: 10, FontStretch: normal, FontStyle: normal, FontWeight:normal, ToolTip:toolTipText, HorizontalAlignment:Center, VerticalAlignment:Center, TextAlignment:Center)
  • Foreground, Background - цвет шрифта и фона, задается как имя цвета, например Yellow или компонентно с альфа-каналом (слева-направо #ПрозрачностьКрасныйЗеленыйСиний в шестнадцатеричном формате), например #A0FF0000.

  • Tooltip - текст подсказки к ячейке или строке, будет появляться через долю секунды после наведения мыши.

  • HorizontalAlignment, VerticalAlignment, TextAlignment - выравнивание, применяемое к элементу.

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

Это описание можно задать:

  • Прямо в мета-информации представления, в этом случае нужно задать Alias. Например, вы подсвечиваете строки всего двумя цветами, в этом случае можно задать два оформления с именами RedColumn, GreenColumn. Далее в выборке в колонке с именем, скажем, ColumnColor вы в зависимости от данных в других колонках - формируете строку "RedColumn" или "GreenColumn". И затем имя этой колонки используете в #view(Appearance:ColumnColor) для подсветки целых строк или в #column(Appearance:ColumnColor) для подсветки конкретной колонки.

  • Сформировать в какой-то колонке выборки как строку вида "#appearance(Foreground: Black, Background: Yellow)" и далее использовать колонку в #view(Appearance:ColumnAlias) для подсветки целых строк или в #column(Appearance:ColumnAlias) для подсветки конкретной колонки. В этом случае вы можете использовать гораздо более гибкое оформление.

Для использования подсветки есть два способа:

  1. Задать #view(Appearance: ColumnName). В указанной колонке представления либо алиас заданного в метаинфе оформления, либо строка "#appearance(..). В результате вся строка представления оформляется в указанном стиле.

  2. Задать #column(Appearance: ColumnOrAppearanceAlias). Если указан алиас, то вся колонка целиком будет оформлена в указанном стиле. Если указано имя колонки с оформлением, то в этой колонке либо алиас заданного в метаинфе оформления, либо строка "#appearance(..) и тогда каждая строка этой колонки будет оформлена в стиле, заданном в колонке с оформлением.

Вы можете посмотреть пример в представлении MyTasks (Мои задания), где в зависимости от степени просроченности задания оно подсвечивается красным цветом разной степени прозрачности.

4.3.7. Условный оператор #if и другие

В настоящий момент рекомендуется всегда использовать оператор #if. Операторы #if_def и прочие оставлены в платформе для совместимости.
#if(expression) {
 ... text ...
}
{
 ... else text ...
}

Текст внутри блока …​ text …​ будет вставлен в представление если условие expression истинно, в противном случае в представление будет вставлен текст …​ else text …​

Выражение expression соответствует синтаксису, используемому в C#.

В выражении можно использовать алиасы параметров, подвыборок, функции и классы .Net Framework.

Проверка наличия на входе параметра или Подмножества с алиасом Param1:
#if(Param1) {... text ...}
Проверка отсутствия на входе параметра или Подмножества с алиасом Param1:
#if(!Param1) {... text ...} {... else text ...}
Проверка по значению параметра:
#if(Param1 && Param1.Value == 10) { ... text...} { ... else text ...}
Проверка наличия заданных параметров:
#if(any) { ... text ...}
Проверка наличия сортировки по определенной колонке
#if(request.SortedBy("Alias"))
Проверка порядка сортировки по определенной колонке. Этот и предыдущий пример полезны, если нужно осуществить супер-сложную сортировку при щелчке по какой-то колонке. Метод SortDirection возращает "asc"\"desc"\null.
#if(request.SortDirection("Alias") == "asc")
Проверка количества колонок в сортировке (если вы щелкнете по заголовку колонки с нажатой кнопкой Shift, то эта колонка добавится к уже заданной)
#if(request.SortingColumns.Count == 1)
Проверка заданного поискового параметра TypeId и с каким именно критерием он используется
#if (TypeID && TypeID.CriteriaName == "IsNull")
Использование алиасов в условном операторе #if
  • Alias – Псевдоним параметра или Подмножества.

  • Alias.Value – Получение единственного значения, единственного условия для параметра с именем Alias

  • Alias.Value1 – Получение первого значения единственного условия для параметра с именем Alias

  • Alias.Value2 – Получение второго значения единственного условия с именем Alias

  • Alias.ValueIsNull, Alias.Value1IsNull, Alias.Value2IsNull - Возвращает true, если параметр с именем Alias задан, но при этом значение в диалоге фильтрации не выбрано.

  • Alias.ValueCount – Получение количества установленных значений для параметра с именем Alias.
    Возможные значения:

    • -1 - Параметр не задан или задано более одного условия

    • 0 - Задано условие которые не поддерживает задание значений для параметров(IsNull, IsNotNull, IsTrue и т.п.)

    • 1 - Задано одно значение (условия Equals, Not Equals и т.п.)

    • 2 - Задано два значения (условие Between)

  • Alias.CriteriaCount – Получение количества в заданных условиях для параметра Alias. Если параметр не задан возвращает – 1, в противном случае количество условий.

  • Alias.CriteriaName – Возвращает имя первого условия или пустую строку если условие не задано.

#if_def(Alias1,Alias2, ...) { ... text ... }
Устаревший, используйте #if.

Текст внутри блока …​text…​ будет вставлен в представление, если на входе определен указанный алиас параметра или Подмножества или их набор.

#if_def_else(Alias1, Alias2, ...) { ... text ... } { ... else text ... }
Устаревший, используйте #if.

Текст внутри блока …​text…​ будет вставлен в представление, если на входе определен указанный алиас параметра или Подмножества или их набор. В противном случае в представление будет вставлен текст …​ else text …​

#if_not_def_else(Alias1, Alias2, ...) { ... text ... } { ... else text ... }
Устаревший, используйте #if.

Текст внутри блока …​text…​ будет вставлен в представление, если на входе не определен указанный алиас параметра или Подмножества или их набор.

В противном случае в представление будет вставлен текст …​ else text …​

В условных операторах допускается возможность использования следующих специальных алиасов.

Administrator

Алиас определен, если пользователь, для которого происходит построение представления, является администратором

Subset

Алиас определен, если построение происходит в режиме Подмножества

Normal

Алиас определен, если построение происходит в режиме представления.

4.3.8. #param_expr

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

#param_expr(EmployeeID, t2.ID)

Разворачивается в конструкцию вида and @EmployeeID_1 = t2.ID или and @EmployeeID_1 = t2.ID OR @EmployeeID_2 = t2.ID и т.д., если значений несколько с учетом типа входящих данных.

#param_expr(EmployeeID)

Разворачивается в конструкцию вида @EmployeeID_1. Используется, если по каким-то причинам нужно получить в запросе реальный параметр SQL SErver и самим построить выражение фильтра или поиска по нему.

4.3.9. #columns

#columns {
	Col1,
	Col2,
	Col3
}

Опеределяет основной список выборки. Текст внутри блока вставляется в запрос, только если представление работает в обычном режиме, а не режиме Подмножества.

Аналогичным образом работает #if(Normal)

4.3.10. #order_by

#order_by

Формирует текущий список сортировки вида t1.Column1 asc, t2.Column2 desc для вставки в запрос. В случае, если представление выполняется в режиме Подмножества, разворачивается в пустую строку.

4.3.11. #extension

#extension(TypeName: Имя типа, Order:Порядковый номер)

Описывает расширение, применяемое к представлению в TessaClient. Необязательный. Описывается в метаданных представленя.

Расширение является классом C# реализующим интерфейс IWorkplaceExtension<in TModel> и зарегистрированным в реестре расширений IWorkplaceExtensionRegistry.

Атрибуты
  • Имя типа - Обязательное. Валидное имя типа зарегистированное в реестре расширений. Например Tessa.Extensions.Default.Client.Views.CustomButtonWorkplaceComponentExtension.

  • Order – Обязательное. Порядковый номер применения расширения к представлению.

Примеры реализации расширений можно увидеть в пространстве имен Tessa.Extensions.Default.Client.Views проекта Tessa.Extensions.Default.Client

4.3.12. #eval

#eval(выражение на C#)

Позволяет использовать выражения C# в тексте запроса. Значение полученное в результате выполнения записывается в формируемый текст запроса. Внутри текста выражение возможно использовать обращение к параметрам запроса аналогичное используемому в #if.

4.3.13. #var

#var(VariableName: выражение на C#)

Позволяет создать именованную переменную в тексте запроса, присваивая ей результат выражения. В дальнейшем переменная может использоваться в конструкциях #var, #if, #eval. Имя переменной должно быть валидным именем C#. Переменная должна быть описана перед первым использованием, желательно в начале текста запроса. Тип значения переменной выводится на основании результата выражения заданного в ней.

Примеры использования:

#var(MyVariable: Param1 || !Param2 || Param3 && Param3.CriteriaName == "Equality")
#var(CurrentDateTime: DateTime.UtcNow)
#var(Message: "Текущее время:" + CurrentDateTime.ToString())
...
#if(OtherParam && MyVariable)
{
  #eval(Message)
}

Переменную можно переопределять в зависимости от условий, помещать внутрь блока #if. В операторах #var, так же, как и в #eval, можно ссылаться на значение ранее определенных переменных по их имени.

Приведенный ниже пример будет работать как для числовых переменных, так и для строк.

#var(var1:Param1.Value)

#if(Param2)
{
  #var(var1:var1 + Param2.Value)
}

Select '#eval(var1)'

4.3.14. Подмножества (SubSet)

Подмножества предназначены для построения различных связанных выборок на основе данных представления.

Например, для представления Мои задания может быть определена подвыборка По контрагенту - она вернет список всех контрагентов, которые указаны в карточках, по которым у вас есть задания. Подвыборка выполняется всегда с учетом текущих заданных параметров представления. Если представление отфильтровано по Статус = Не начато, то выборка контрагентов вернет только тех контрагентов, которые указаны в карточках с неначатыми заданиями.

Пример представления, показывающего список ролей, с подмножеством по типу роли
#view(DefaultSortColumn: RoleName, DefaultSortDirection: asc, Paging: always, RowCountSubset: Count)
#column(Alias: RoleID, Hidden: true)
#column(Alias: RoleName, Caption: Роль, SortBy: r.Name)
#column(Alias: TypeName, Caption: Тип роли, Localizable: true)
#column(Alias: rn, Hidden: true)
#param(Alias: Name, Caption: Имя, Hidden: false, Type: nvarchar, Multiple: true)
#param(Alias: TypeID, Caption: Тип, Hidden: false, Type: int, Multiple:false, RefSection: RoleTypes)
{
	#autocomplete(View: RoleTypes, Param: RoleTypeNameParam, RefColumn: RoleTypeID, PopupColumns: 1)
	#dropdown(View: RoleTypes, PopupColumns: 1)
}
#reference(ColPrefix: Role, RefSection: Roles, DisplayValueColumn: RoleName, IsCard: true, OpenOnDoubleClick: true)

#subset(Alias: RoleTypes, Caption:$Views_Roles_ByType, CaptionColumn: Name, RefColumn: ID, RefParam: TypeID)
#subset(Alias: Count)
select *
from
(
	select
		#if(Normal) {
		r.ID as RoleID,
		r.Name as RoleName,
		r.TypeID as TypeID,
		rt.Name as TypeName,
		row_number() over (order by #order_by) as rn
		}
		#if(RoleTypes) { /*Выборка, которая выполняется в режиме подмножества по типам ролей*/
		distinct
			rt.ID,
			rt.Name
		}
		#if(Count) {
		count(*) as cnt
		}
	from Roles r with(nolock) inner join
		  RoleTypes rt with(nolock) on rt.ID = r.TypeID
	where r.TypeID <> 6 /* Не показываем временные роли заданий */
		    and r.Hidden = 0 /* Не показываем скрытые роли */
   		    #param_expr(TypeID, r.TypeID) /* В этот параметр приходит идентификатор типа, выбранного пользователем в подмножестве*/
		    #param_expr(Name, r.Name)

) t
#if(PageOffset) {
where t.rn >= #param_expr(PageOffset) and t.rn < (#param_expr(PageOffset) + #param_expr(PageLimit))
order by t.rn
}
И результат

image65

Подмножества могут быть трех разных типов:

  • В списке показывается некое отображаемое значение, а фильтрация идет по скрытому идентификатору (в этом случае подвыборка возвращает две колонки - идентификатор и отображаемая строка). Такое подмножество RoleTypes на примере выше.

  • В списке показывается то же самое значение по которому идет фильтрация (в этом случае выборка может возвращать одну и ту же колонку)

  • В списке показывается в дополнение к значениям еще и количество строк с этим значением (в этом случае выборка возвращает дополнительную строковую колонку).

Система сама понимает, какой из вариантов (в зависимости от параметров, указанных в #subset и формирует соответствующий пользовательский интерфейс.

Например, вариант предыдущего представления с количеством ролей различных типов:
#view(DefaultSortColumn: RoleName, DefaultSortDirection: asc, Paging: always, RowCountSubset: Count)
#column(Alias: RoleID, Hidden: true)
#column(Alias: RoleName, Caption: Роль, SortBy: r.Name)
#column(Alias: TypeName, Caption: Тип роли, Localizable: true)
#column(Alias: rn, Hidden: true)
#param(Alias: Name, Caption: Имя, Hidden: false, Type: nvarchar, Multiple: true)
#param(Alias: TypeID, Caption: Тип, Hidden: false, Type: int, Multiple:false, RefSection: RoleTypes)
{
	#autocomplete(View: RoleTypes, Param: RoleTypeNameParam, RefColumn: RoleTypeID, PopupColumns: 1)
	#dropdown(View: RoleTypes, PopupColumns: 1)
}
#reference(ColPrefix: Role, RefSection: Roles, DisplayValueColumn: RoleName, IsCard: true, OpenOnDoubleClick: true)

#subset(Alias: RoleTypes, Caption:По типу роли, CaptionColumn: Name, RefColumn: ID, RefParam: TypeID, CountColumn: cnt) (1)
#subset(Alias: Count)
1 Обратите внимание, что теперь мы указываем, в какой колонке запроса лежит количество.
select *
from
(
	select
		#if(Normal) {
		r.ID as RoleID,
		r.Name as RoleName,
		r.TypeID as TypeID,
		rt.Name as TypeName,
		row_number() over (order by #order_by) as rn
		}
		#if(RoleTypes) { /*Выборка, которая выполняется в режиме подмножества по типам ролей*/
			rt.ID,
			rt.Name,
			count(*) as cnt                          (1)
		}
		#if(Count) {
		count(*) as cnt
		}
	from Roles r with(nolock) inner join
		  RoleTypes rt with(nolock) on rt.ID = r.TypeID
	where r.TypeID <> 6 /* Не показываем временные роли заданий */
		    and r.Hidden = 0 /* Не показываем скрытые роли */
   		    #param_expr(TypeID, r.TypeID) /* В этот параметр приходит идентификатор типа, выбранного пользователем в подмножестве*/
		    #param_expr(Name, r.Name)
	#if(RoleTypes) { /*Теперь мы хотим группировку*/ (2)
	group by rt.ID, rt.Name
	}
) t
#if(PageOffset) {
where t.rn >= #param_expr(PageOffset) and t.rn < (#param_expr(PageOffset) + #param_expr(PageLimit))
order by t.rn
}
1 Считаем количество
2 Не забываем сделать группировку в режиме этого подмножества
Результат

image64

4.4. Древовидные подмножества

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

Для настройки сабсета в режиме дерева используются параметры:

  • Kind: Tree

  • TreeRefParam: ParamAlias. Алиас параметра для выборки узлов дерева по родительскому.

  • TreeHasChildrenColumn: ColumnAlias. Алиас колонки выборки, которая содержит признак наличия дочерних узлов (0,1).

В этом случае представление работает так:

  1. Когда пользователь разворачивает сабсет, система выполняет сабсет с заданным параметром TreeRefParam = NULL. Получая таким образом верхние узлы дерева.

  2. При разворачивании узла система вновь выполняет сабсет, передавая идентификатор узла в TreeRefParam, получая таким образом его дочерние узлы.

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

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

В типовой конфигурации древовидные подмножества используются в представлении "Департаменты\Departments".

image66

4.5. Контекстные параметры

Система автоматически определяет следующие параметры, зависящие от контекста

  • CurrentUserID - идентификатор текущего пользователя

  • PageOffset - если для представления включен пейджинг (зависит от настроек представления), то эта переменная определена и содержит индекс первой строки, которую нужно вернуть.

  • PageLimit - аналогично предыдущему, переменная содержит количество строк, которые нужно вернуть.

  • Locale - ID локали текущего пользователя

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

4.6. Корректная реализация постраничного вывода

4.6.1. Параметры постраничного вывода(пейджинга)

  • PageOffset - номер строки с которой необходимо начать получение данных.

  • PageLimit - количество строк которые система пытается получить от представления.

При использовании для получения номеров строк функции SQL Server row_number() нумерация строк начинается с 1.

Для корректной реализации пейджинга в представлениях следует использовать следующие конструкции.

where t.rn >= #param_expr(PageOffset) and t.rn < (#param_expr(PageOffset) + #param_expr(PageLimit))

или

where t.rn between #param_expr(PageOffset) and (#param_expr(PageOffset) + #param_expr(PageLimit)-1)
Система всегда запрашивает у представления на 1 строку больше, чем видит пользователь. Это позволяет определить, существует ли следующая страница данных. Например, если страница - 20 строк, то представление будет запрашивать 21 строку. Если ему вернулась 21 строка, система покажет пользователю 20 и будет доступен переход на следующую страницу. Если вернулось 20 или меньше - это последняя страница.

4.7. Отладка представлений

При разработке представлений можно пользоваться различными возможностями отладки.

4.7.1. Быстрый просмотр

На вкладке "Просмотр" можно сразу же посмотреть в интерфейсе пользователя данные, возвращаемые представлением.

image61
Не забывайте, что если в представлении есть подмножество Count, то оно тоже будет выполняться. Если система вам показывает ошибку в текст, но вы не понимаете ее источник и текст верный - возможно проблема в некорректном тексте запроса в режиме подмножества Count.

4.7.2. Отладка

На вкладке "Отладка" можно посмотреть, какой именно текст запроса генерирует платформы в различных ситуациях, при различных параметрах или в режимах различных подмножеств.

image62

В правой области можно задать необходимые параметры или режим подмножества в выпадающем списке внизу.

Далее по нажатию одной из кнопок image63 система формирует текст запроса и может сразу выполнить его. В левой области вы видите текст запроса вверху и результат его выполнения внизу.

4.8. Программное использование и расширение представлений.

Существуют следующие возможности использования и расширения представлений:

  • Вызов представления на клиенте или на сервере

  • Создание программных представления на клиентской стороне.

  • Создание программных представлений не серверной стороне.

  • Перехват обработки представлений на серверной стороне

Пример выполнения представления из кода
/*
* Вызов представления контрагентов для поиска подходящих по имени
* Код будет одинаковым и на клиенте и на сервере. Разница будет только в
* наборе доступных представлений. На клиент доступны только те, на которые
* у текущего пользователя есть права, на сервере - все имеющиеся.
*/

// Получаем ссылку на объект IViewService, например, через параметры конструктора
// MyExtensionContructor(IViewService viewService, ...), который затем сохраняем в
// поле нашего класса

// Получаем представление Контрагенты по его алиасу
var view = viewService.GetByName("Partners");
// Находим параметр Name
var paramMetadata = view.Metadata.Parameters.FindByName("Name");
// Начинаем формировать реквест
var request = new TessaViewRequest(view.Metadata);
// Добавляем новый критерий поиска - по параметру Name, используем оператор "Содержит"
var parameter = new RequestParameterBuilder()
	.WithMetadata(paramMetadata)
	.AddCriteria(new ContainsCriteriaOperator(), name, name)
	.AsRequestParameter();
request.Values.Add(parameter);

// Указываем системные параметры - нам нужно 10 строк начиная с 1-й
// Также можно резолвить в конструкторе объект через интерфейс IViewSpecialParameters
IVIewPagingParameters specialParameters = new ViewPagingParameters();
specialParameters.ProvidePageLimitParameter(request.Values, Paging.Always, 10, false);
specialParameters.ProvidePageOffsetParameter(request.Values, Paging.Always, 1, 10, false);

// Выполняем реквест
var result = view.GetData(request);

// Проверяем наличие данных в результате
if (!result.Rows.Any())
{
	return;
}

// Определяем индексы колонок с нужными нам алиасами
var columns = result.Columns.Cast<string>().ToList();
var idIndex = columns.IndexOf("PartnerId");
var fullNameIndex = columns.IndexOf("FullName");

// Нас интересует первая строка результата. Строка - это массив значений колонок.
var firstRow = (IList<object>)result.Rows.First();

// Получаем из первой строки результата идентификатор и имя контрагента
var partnerId = (Guid)firstRow[idIndex];
var partnerName = (string)firstRow[fullNameIndex];

// Используем результат...
Примеры программных расширений для представлений
  • В Tessa Client в папке Администратор\Расширения представлений\Список файлов. Это программное представление, которое показывает список файлов (и папок), расположенных на сервере приложений системы в папке c:\temp\1. Обратите внимание, что представление представляет подмножество, показывающее дерево подпапок в этой папке, а также поисковый параметр.

    image67
    Алиас этого представления ViewFiles (см. в Tessa Admin), исходный код доступен в файле Extensions\Tessa.Extensions.Default.Server\Views\ViewsIntercepter.cs, регистрация данного расширения выполняется в методе Extensions\Tessa.Extensions.Default.Server\ServerApplicationConfigurator.cs#RegisterViews.
  • В Tessa Client в папке Администратор\Расширения представлений\Генератор. Это представление по умолчанию ничего не показывает. Но как только вы зададите его поисковые параметры "Текст" и "Количество" - оно тут же покажет столько строк, сколько вы указали в количестве с текстом, указанным в параметре.

    image68
    Данное представление не видно в Tessa Admin, оно полностью генерируется на сервере, исходный код доступен в файле Extensions\Tessa.Extensions.Default.Server\Views\ViewsExtraProvider.cs, регистрация данного расширения выполняется в методе Extensions\Tessa.Extensions.Default.Server\ServerApplicationConfigurator.cs#RegisterViews.

Описание интерфейса ITessaView

    /// <summary>
    ///     Базовый интерфейс представления.
    ///     Предназначен для имплементации представлений.
    ///     Представления - произвольные источники данных
    ///     позволяющие выполнять к ним <see cref="ITessaViewRequest">запросы</see>
    ///     на получение <see cref="ITessaViewResult">данных</see>.
    ///     Представление содержит <see cref="IViewMetadata">метаданные</see>
    ///     описывающие возможные параметры запроса к представлению
    ///     и детали визуализации результата.
    /// </summary>
    public interface ITessaView
    {
        #region Public Properties

        /// <summary>
        ///     Gets метаданные представления
        /// </summary>
        [NotNull]
        IViewMetadata Metadata { get; }

        #endregion

        #region Public Methods and Operators

        /// <summary>
        /// Выполняет получение данных из представления
        ///     на основании полученного <see cref="ITessaViewRequest">запроса</see>
        /// </summary>
        /// <param name="request">
        /// Запрос к представлению
        /// </param>
        /// <returns>
        /// <see cref="ITessaViewResult">Результат</see> выполнения запроса
        /// </returns>
        [NotNull]
        ITessaViewResult GetData([NotNull] ITessaViewRequest request);

        #endregion
    }

Описание интерфейса ITessaViewAccess.

    /// <summary>
    ///     Интерфейс предоставляющий информацию о доступе к представлению.
    /// </summary>
    public interface ITessaViewAccess
    {
        #region Public Properties

        /// <summary>
        ///     Gets метаданные представления
        /// </summary>
        [NotNull]
        IViewMetadata Metadata { get; }

        #endregion

        #region Public Methods and Operators

        /// <summary>
        ///     Возвращает список ролей, которые необходимы для доступа к
        /// представлению,
        ///     реализующему данный интерфейс
        /// </summary>
        /// <returns>
        ///     Список ролей
        /// </returns>
        [NotNull]
        IEnumerable<Role> GetRoles();

        #endregion
    }

Описание интерфейса IExtraViewListProvider

	/// <summary>
    ///     Интерфейс возвращающий список программных представлений
    /// </summary>
    public interface IExtraViewListProvider
    {
        #region Public Methods and Operators

        /// <summary>
        ///     Возвращает список программных представлений
        /// </summary>
        /// <returns>Список программных представлений</returns>
        [NotNull]
        IEnumerable<ITessaView> GetExtraViews();

        #endregion
    }

Описание интерфейса ITessaViewOverlay

    /// <summary>
    ///     Интерфейс возвращающий список программных представлений
    /// </summary>
    public interface IExtraViewListProvider
    {
        #region Public Methods and Operators

        /// <summary>
        ///     Возвращает список программных представлений
        /// </summary>
        /// <returns>Список программных представлений</returns>
        [NotNull]
        IEnumerable<ITessaView> GetExtraViews();

        #endregion
    }

Описание интерфейса IViewInterceptor

    /// <summary>
    ///     Интерфейс перехватчика представлений
    /// </summary>
    public interface IViewInterceptor
    {
        #region Public Properties

        /// <summary>
        ///     Gets список обрабатываемых представлений
        /// </summary>
        [NotNull]
        string[] InterceptedViews { get; }

        #endregion

        #region Public Methods and Operators

        /// <summary>
        /// Осуществляет выполнение запроса на получение данных
        /// </summary>
        /// <param name="request">
        /// Запрос
        /// </param>
        /// <returns>
        /// Результат обработки
        /// </returns>
        [NotNull]
        ITessaViewResult GetData([NotNull] ITessaViewRequest request);

        /// <summary>
        /// Вызывает инициализацию перехватчика, передавая в него
        ///     список перехватываемых представлений <paramref name="overlayViews"/>
        /// </summary>
        /// <param name="overlayViews">
        /// Список перехватываемых представлений
        /// </param>
        void InitOverlay([NotNull] IDictionary<string, ITessaView> overlayViews);

        #endregion
    }

4.8.1. Создание программных представлений на клиентской стороне.

Для создания представления на клиентской стороне необходимо реализовать представление в виде класса, имплементирующего интерфейс ITessaView. Для того чтобы клиентское представление стало доступно на клиенте через сервис представлений IViewService, необходимо осуществить его регистрацию в контейнере приложения. При регистрации в контейнере необходимо указать алиас представления, под которым оно будет доступно системе. Регистрация клиентских представлений осуществляется в классе ClientApplicationConfigurator. представление должно регистрироваться с уникальным именем желательно совпадающим с алиасом представления, в противном случае в системе будет зарегистрировано представление последним осуществившее регистрацию в контейнере приложения. В системе представление будет доступно под алиасом определенным в его метаданных. В случае если на серверной стороне было объявлено представление с таким же алиасом, то представление на клиентской стороне замещает его и все запросы к представлению будут обрабатываться клиентским представлением.

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

Пример регистрации клиентского представления в контейнере приложения

/// <summary>
/// Осуществляет регистрацию расширений в контейнере приложения
/// </summary>
/// <param name="container">
/// Основной контейнер приложения
/// </param>
public void ConfigureContainer(IUnityContainer container)
{
var extensionContainer = container.Resolve<IExtensionContainer>();
       this.ConfigureExtensions(extensionContainer, container);
       container.RegisterType<ITessaView, ClientProgramView>("ClientProgramView",
       new ContainerControlledLifetimeManager());
}
Пример реализации клиентского программного представления можно увидеть в Tessa.Extension.Default.Client.Views.ClientProgramView.cs

4.8.2. Создание программных представлений на серверной стороне

Для создания представления на серверной стороне необходимо реализовать представление в виде класса, имплементирующего интерфейс ITessaView. В отличии от клиентских представлений серверные представления доступны как на серверной стороне, так и на клиентской. В случае необходимости разграничения доступа к серверному представлению существует возможность ограничить возможность вызова представления определенными ролями. Для реализации разграничения доступа необходимо реализовать в классе представления метод GetRoles() интерфейса ITessaViewAccess, возвращающий список ролей, которым доступно представление.

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

Пример реализации поставщика серверных представлений и примеры реализации самих серверных представлений доступны в Tessa.Extension.Default.Server.Views.ViewsExtraProvider.cs

4.8.3. Перехват обработки представлений на серверной стороне.

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

Примеры перехватчиков можно посмотреть в Tessa.Extension.Default.Server.Views.ViewsInterceptor.cs и Tessa.Extension.Default.Server.Views.ExampleInterceptor.cs.

4.8.4. Получение текста SQL-запроса на серверной стороне сформированного представлением

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

Для получения текста запроса необходимо привести представление, полученное от IViewsService на серверной стороне к интерфейсу IViewTextGenerator. Текст запроса возвращается методом TryGenerate. В случае если представление не поддерживает генерацию текста SQL-запроса будет возвращена пустая строка, в случае успешного исполнения будет возвращен текста запроса, сформированный по запросу, переданному в метод TryGenerate. Для выполнения запроса можно для текущего соединения можно получить из контейнера IViewQueryExecutor и вызвать его метод Execute.

/// <summary>
/// Описание интерфейса генератора текста sql запроса представления
/// <see cref="ITessaView" />
///     по запросу к представлению <see cref="ITessaViewRequest" />.
/// </summary>
public interface IViewTextGenerator
{
   #region Public Methods and Operators

        /// <summary>
        /// Осуществляет попытку генерации текста SQL запроса к представлению
  /// по запросу <paramref name="request"/>. Если представление не
  /// существует или
        ///     не поддерживает генерацию текста запроса (программные представления),
        ///     то будет возвращена пустая строка.
        ///     Экземпляр представления по которому необходимо
        /// сгенерировать текст запроса
        ///     выбирается из <paramref name="request.Alias"/>
        /// </summary>
        /// <param name="request">
        /// Запрос к представлению
        /// </param>
        /// <returns>
        /// Сгенерированный текст запроса или пустая строка
        /// </returns>
        [NotNull]
        string TryGenerate([NotNull] ITessaViewRequest request);

        #endregion
    }

4.9. Создание отчета с помощью связанных представлений

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

Создадим два простых представления – представление, показывающее количество текущих заданий по типам и представление, показывающее информацию по текущим заданиям.

Представление количества текущих заданий по типам (ExampleCurrentTasksByType):

Метаинформация:
#view(DefaultSortColumn: TypeCaption, DefaultSortDirection: desc, Paging: always)
#column(Alias: TypeCaption, Caption: Тип, Hidden: false)
#column(Alias: cnt, Caption: Количество, Hidden: false)
#param(Alias: RolaNameParam, Caption: Имя роли, Hidden: false, Type: nvarchar)
#param(Alias: TypeNameParam, Caption: Тип задания, Hidden: false, Type: nvarchar)
Запрос
SELECT
    TypeCaption             AS TypeCaption
    ,COUNT(TypeCaption)     AS cnt
FROM Tasks
WHERE 1=1
#param_expr(RolaNameParam, Tasks.RoleName)
#param_expr(TypeNameParam, Tasks.TypeCaption)
GROUP BY TypeCaption

Представление отображает количество заданий каждого типа, для которого есть задания:

image20

Представление по заданиям (ExampleCurrentTasks):

Метаинформация:
#view(DefaultSortColumn: TaskType, DefaultSortDirection: desc, Paging: always)
#column(Alias: CardID, Hidden: true)
#column(Alias: TaskID, Hidden: true)
#column(Alias: TaskType, Caption: Тип, Hidden: false)
#column(Alias: RoleName, Caption: Назначено, Hidden: false)
#param(Alias: RolaNameParam, Caption: Имя роли, Hidden: false, Type: nvarchar)
#param(Alias: TypeNameParam, Caption: Тип задания, Hidden: false, Type: nvarchar)
Запрос
SELECT
    Tasks.ID                            AS CardID
    ,Tasks.RowID                    AS TaskID
    ,Tasks.TypeCaption                 AS TaskType
    ,Tasks.RoleName                 AS RoleName
FROM Tasks
LEFT JOIN TaskCommonInfo ON Tasks.RowID = TaskCommonInfo.ID
WHERE 1 = 1
#param_expr(RolaNameParam, Tasks.RoleName)
#param_expr(TypeNameParam, Tasks.TypeCaption)

Представление отображает некоторую информацию о заданиях:

image21

Теперь перейдем к рабочему месту пользователя и добавим эти представления как связанные:

  1. Сначала добавим представление ExampleCurrentTasksByType как обычное представление

  2. Добавим новое представление как дочернее к ExampleCurrentTasksByType

    image22

    Оно отображается как вложенное в ExampleCurrentTasksByType

    image23
  3. Выберем в его параметрах представление ExampleCurrentTasks

    image24
  4. Теперь мы видим, что наше представление по заданиям вложено в представление по заданиям по типам:

    image25

  5. Разделим область представления на две части по горизонтали

    image26
  6. И добавим в верхнюю часть области родительское представление ExampleCurrentTasksByType, а в нижнюю – ExampleCurrentTasks

    image27
  7. Для дочернего представления доступно связывание параметров представления с параметрами или данными родительского представления.

    Посмотрим как выглядят представления до связывания параметров

    image28

    В дочернем представлении отображается информация по всем заданиям в системе, вне зависимости от фильтров и выбранной строки родительского представления.

  8. Свяжем параметр имени роли дочернего и родительского представлений:

    image29

    Проверим что параметр "пробросился" - т.е. при указании параметра для родительского представления, дочернее представление также фильтруется по указанному значению параметра:

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

    image31

    Проверим:

    image32

4.10. Добавление параметра для полнотекстового поиска

Предварительно необходимо выполнить настройку полнотекстового поиска (см. Руководство по установке), после чего в представление можно добавить параметр для полнотекстового поиска.

Рассмотрим на примере представления Мои документы (MyDocuments):

  1. В Метаданные представления добавляем новый #param:

    #param(Alias: Content, Caption: Поиск по файлам, Hidden: false, Type: nvarchar, Multiple: false, AllowedOperands: Contains)
  2. В Запрос добавляем параметр nvarchar(4000) (потому что исходный параметр указан как nvarchar(max), и такое значение недопустимо для функции FREETEXT для полнотекстового поиска):

    #if(Content) {
    declare @ContentParam nvarchar(4000) = #param(Content)
    }
  3. В Запрос, в условие where добавляем полнотекстовый поиск по содержимому всех индексированных файлов в карточке (по пустым строкам искать нельзя, поэтому явно проверяем, что введённая строка в фильтре не пустая и не состоит только из пробелов):

    #if(Content && !string.IsNullOrWhiteSpace(Content.Value)) {
    and exists (
    	select 1
    	from Files f with(nolock)
    	inner join FileVersions fv with(nolock) on fv.ID = f.RowID
    	inner join [tessa-files].[dbo].[FileContent] ft with(nolock) on ft.VersionRowID = fv.RowID
    	where f.ID = t.ID
    		and freetext(ft.Content, @ContentParam)
    )
    }
В примере указано, что таблица FileContent расположена в базе данных с именем tessa-files на том же сервере баз данных. Если имя базы данных отличается, база данных расположена на связанном сервере (linked server) или таблица FileContent задана в текущей базе данных, то измените имя [tessa-files].[dbo].[FileContent] в соответствии с вашими требованиями. Рекомендации по настройке базы данных для полнотекстового индексирования приведены в Руководстве по установке

Сохраняем представление и проверяем: в представлении появился добавленный нами поисковый параметр, который будет выполнять поиск строки в приложенных к карточкам файлах:

image32 1

4.11. Особенности работы с PostgreSQL

В данном разделе описаны особенности написания представлений при использовании СУБД PostgreSQL.

  • Имена таблиц и колонок, в которых есть заглавные буквы, нужно заключать в двойные кавычки. Например: "DocumentCommonInfo".

  • Аналог cross applylateral join.

  • PostgreSQL считает count(*) медленнее, чем MSSQL. Отказывайтесь от сабсета Count там, где данных много. Если представление используется и в MSSQL, и в PostgreSQL, Count можно отключить только для PostgreSQL, используя #if в метаинформации. Пример в представлении Documents.

  • При поиске по части строки с использованием TRIGRAM индексов (pg_tgrm) учитывайте, что длина входящей строки должна быть не меньше 3 символов, иначе PostgreSQL будет обходить весь индекс, что очень медленно, т.е. по факту индекс в таком случае работать не будет. В плане выполнения нет никакой разницы, кроме огромной стоимости.

    Например:

    Where Column like '%123%' – будет работать, а

    Where Column like '%12%' - будет выполняться очень медленно.

  • Рекомендуется использовать колонки с типом Int16, вместо типа Byte, т.к. в PostgreSQL они преобразуются в Int16, а в MSSQL остаются как Byte, что приводит к разному поведению при чтении из базы в коде. Актуально, если вы сейчас на MSSQL, но планируете мигрировать на PostgreSQL, или же вы пишете универсальный модуль, работающий с любой СУБД.

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

Пример индекса

Когда используется

b-tree индекс по полю без lower и без text_pattern_ops, в том числе в составных индексах.

create index .. on ...
using btree
(
“Name”
)

Используется в следующих случаях:

  1. Когда нужен быстрый поиск на равенство с учетом регистра.

    Select * from "Table" where "Name" = 'something'

    В системе документооборота Tessa это обычно только штрих-код.

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

select * from "Documents" order by "FullNumber"

Важно быть уверенными, что индекс действительно используется системой и сортировка не выполняется в памяти системы. Если запрос написан некорректно (например, есть лишние условия выборки, из-за которых оптимизатор выбирает другой индекс), то польза от такого индекса только в 1-м случае (поиск по равенству).

В системе документооборота Tessa это обычно номера документов, краткие имена контрагентов и сотрудников (ролей).

b-tree индекс по полю c lower и без text_pattern_ops, в том числе в составных индексах.

create index .. on ...
using btree
(
lower("Name")
)

Используется в следующих случаях:

  1. Когда нужен быстрый поиск на равенство без учета регистра.

    Например, в Tessa это логин пользователя. Система при логине ищет без учета регистра.

    Важно не забывать, что в запросе должно быть написано where lower("name") = 'name', иначе работать не будет.

  2. Когда нужна проверка на уникальность без учета регистра средствами базы. В этом случае индекс должен быть unique.

Gin + pg_tgrm индекс по полю с использованием lower.

create index .. on ...
using gin
(
lower("Name") gin_trgm_ops
)

Используется, когда нужен эффективный поиск с использованием паттернов.

Select * from "Documents"
where lower("Name") like '%sometext%'

Важно не забывать, что в запросе должно быть написано where lower("name") like .., иначе работать не будет.

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

В системе документооборота Tessa это обычно полный номер документа, имя контрагента, имя роли, тема документа.

Иногда может быть нужен и b-tree, и gin индекс.

Полнотекстовый индекс по колонке

GIN индекс по to_tsvector(column)

Используется, когда нужен быстрый и эффективный поиск по лексемам. Работает с учетом морфологии, но не позволяет искать по части слова – только по целым словами.

Например, для поиска по части имени контрагента такой индекс не подойдет, для этих целей используются pg_tgrm индексы.

select *
from "SuperDocuments" sd,
    'май | первый' q
where to_tsquery(sd.Subject) @@ q;

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

Для того, чтобы сделать immutable функцию (только такие функции можно использовать в индексах), нужно в теле функции написать:

CREATE FUNCTION make_tsvector(title TEXT, content TEXT)
   RETURNS tsvector AS $$
BEGIN
  RETURN (setweight(to_tsvector('english', title),'A') ||
    setweight(to_tsvector('english', content), 'B'));
END
$$ LANGUAGE 'plpgsql' IMMUTABLE;

Более подробно:

b-tree индекс по полю c lower и с text_pattern_ops, в том числе в составных индексах

create index .. on …​ using btree ( lower("Name") text_pattern_ops )

В НАСТОЯЩИЙ МОМЕНТ НЕ ПОДДЕРЖИВАЕТСЯ!

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

select * from "SuperDocuments"
where TypeId = ... and
            Date = ...  and
            Subject like '%sometext%'

В этом случае, необходимо на примерах убедиться, что индекс действительно используется и что такое использование более эффективное, чем gin + pg_tgrm

Gist + pg_tgrm индекс по полю с lower

Рекомендуется использовать только в случае, если в этом действительно есть необходимость.

Важные отличия индексов GIN и GiST:

  • GIN — быстро ищет, но не слишком быстро обновляется. Отлично работает, если вы сравнительно редко меняете данные, по которым ищите;

  • GiST — ищет медленнее GIN, зато очень быстро обновляется. Может лучше подходить для поиска по очень часто обновляемым данным.

5. Локализация

На вкладке "Локализация" приложения TessaAdmin можно редактировать библиотеки локализации. Каждая библиотека содержит список локализуемых строк.

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

image33

Подробно редактирование библиотек локализации описано в руководстве администратора.

В данном руководстве описано использование строк локализации для настройки системы и расширений.

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

Терминология
  • Когда мы говорим про использование $Name или просто алиаса локализованной строки, это значит, что в данном месте можно указать $Name и система автоматически отобразит локализованное значение.

  • Когда мы говорим про использование форматной строки или плейсхолдеров {$Name}, имеется в виду что в данном месте можно указать строчку вида Text {$Name1} more text {$Name2}, где при отображении система автоматически заменит в форматной строке алиасы локализованных строк на их значения и отобразит результат.

Далее описаны все места, где можно использовать строки локализации.

Метаинформация представлений

  • Поле Caption объектов Column, Param, Subset

Вывод локализованного представления в колонке представления

Для колонки надо выставить #column(Localizable: true) и затем: * Можно использовать "$Name" или плейсхолдеры \{$Name} для форматной строки

Получение значения локализованной строки из SQL

  • Функция GetString(@name, @culture) - Получает на вход алиас строки (без $) и культуру, возвращает локализованную строку или $name, если локализация не нашлась

  • Функция Localization(@name, @culture) - Локализует строку @name, заданную как $Name, для культуры с кодом @culture, и возвращает таблицу с единственной колонкой Value и единственной строкой.

    Если соответствующей строки локализации нет, то возвращает исходную строку @name.

    Если строка не начинается с $, то возвращает исходную строку @name.

    Строка возвращается всегда, поэтому использовать можно через cross apply dbo.Localization(t.Name, @culture)

  • Функция Localize(@name, @culture) - Локализует строку @name, заданную как $Name, для культуры с кодом @culture.

    Если соответствующей строки нет, то возвращает исходную строку @name.

    Если строка не начинается с $, то возвращает исходную строку @name.

Для доступа к идентификатору клиентской локали из кода представлений используется автоматически определяемый системой параметр Locale. Использовать как обычный параметр #param_expr(Locale…​).

Везде где можно, рекомендовано пользоваться авто-локализуемыми колонками с #column(Localizable: true)

Рабочие места

  • $Name можно подставить в имя рабочего места, имя папки, заголовок узла с представлением, заголовок подмножества

Формы карточек и заданий

  • Заголовок типа карточки: $Name

  • Заголовок вкладки, блока, контрола, колонки таблицы: $Name

  • Поле "Сообщение об ошибке" валидаторов not null: $Name или плейсхолдеры {$Name}

  • Колонка.Формат строки можно использовать плейсхолдеры {$Name} и {0},{1}…​. Плейсхолдерами могут быть номера связанных полей и алиасы строк локализации. При этом значения полей связанных строк (в форматной строки они обозначаются через порядковые номера {0}, {1}, …​) также могут быть как вида $Name, так и форматной строкой с плейсхолдерами {$Name}

  • Текст.Формат поля: аналогично "Колонка.Формат строки"

  • Элемент управления.Всплывающая подсказка: можно плейсхолдеры {$Name}, можно строку локализации $Name, можно без локализации, причём переводы строк можно задать как \n

  • Автокомплит(ссылка).Формат поля - может использовать плейсхолдеры строк локализации {$Name} и номера связанных полей карточки {0}

  • Метка.Текст - $Name или плейсхолдеры {$Name}

  • В колонке выпадающего списка автомкомплита(ссылка) (при наборе текста или использовании выпадающего списка) может быть строка в формате $Name. Система автоматически подставит значение вместо алиаса.

    Это произойдет только для отображения!!! После выбора значения в карточку запишется алиас!!!
  • В полях карточки, на которые ссылается автокомплит(ссылка) может быть строка в формате $Name. При формировании текста, который должен отобразиться в контроле, система заменит их на значения.

  • В ячейках таблицы на форме карточки можно использовать $Name или плейсхолдеры {$Name} - для отображения они будут заменены на значения.

Из xaml

  • Расширение разметки {Localize ResourceKey}

Из кода

  • Получаем локаль через LocalizationManager.CurrentUICulture или ISession.ClientUICulture

  • Получаем локализованную строку без доллара LocalizationManager.GetString("Name")

  • Получаем локализованную строку с долларом LocalizationManager.Localize("$Name")

  • Форматируем строку с плейсхолдерами LocalizationManager.Format(" …​ {$Name} …​ ")

Настройки типового решения

  • В поле Название карточки типа документа можно использовать $LocalizedString.

6. Сервис Chronos

Chronos – это приложение, позволяющее периодически запускать сервисы (плагины) как сервис Windows или как приложение в окне консоли (для отладки). Chronos позволяет очень просто реализовать любую фоновую работу или работу по расписанию, которая обычно выполняется написанием и установкой отдельных сервисов Windows. Например, плагин Chronos может пересчитывать динамические роли или удалять карточки из корзины, которые лежат там более 6 месяцев.

6.1. Хост Chronos.exe

Запускается как консольное приложение или устанавливается как сервис Windows.

  • Установка и запуск сервиса (понадобится ввести доменное имя и пароль администратора):

    InstallUtil Chronos.exe
    net start Chronos
  • Остановка сервиса без удаления:

    net stop Chronos
  • Удаление сервиса:

    InstallUtil Chronos.exe /U

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

Хост ищет плагины при помощи MEF внутри задаваемой в конфиге хоста папки Plugins или в её подпапках на 1 уровень вниз. Подпапка плагина должна содержать сборку .DLL плагина, все её референсы и опционально файлы конфигов плагинов (см. ниже).

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

В оснастке 'Службы' сервис называется Syntellect Chronos

Содержимое дерева с плагинами (папки в квадратных скобках)

|
|_ app.json
|_ Chronos.exe
|_ Chronos.exe.config
|_ Chronos.Contracts.dll
|_ Chronos.Platform.dll
|_ Chronos.Platform.Windows.dll
|_ Quartz.dll
|_ Common.Logging.dll
|_ NLog.dll
|_ NLog.config
|_ System.Configuration.ConfigurationManager.dll
|_ System.Runtime.CompilerServices.Unsafe.dll
|_ System.Security.AccessControl.dll
|_ System.Security.Permissions.dll
|_ System.Security.Principal.Windows.dll
|
|_ [ extensions ]
| |
| |_ extensions.xml
| |_ Tessa.Extensions.Default.Server.dll
| |_ Tessa.Extensions.Default.Shared.dll
| |_ Tessa.Extensions.Server.dll
| |_ Tessa.Extensions.Shared.dll
|
|_ [ Plugins ]
  |
  |_ [ MyPlugin1Folder ]
  | |
  | |_ app.json
  | |_ Chronos.Contracts.dll
  | |_ MyPlugin1Assembly.dll
  | |_ MyPlugin1Config.xml
  | |_ plugins.xml
  | |_ extensions.xml
  |
  |_ [ MyPlugin2Folder ]
  | |
  | |_ app.json
  | |_ Chronos.Contracts.dll
  | |_ MyPlugin2Assembly.dll
  | |_ NLog.dll
  | |_ Tessa.dll
  | |_ extensions.xml
  |
  |_ Chronos.Contracts.dll
  |_ MyPlugin3Assembly.dll
  |_ MyPlugin4Assembly.dll
  |_ Some.Useful.Shared.Lib.dll
  |_ plugins.xml
  |_ extensions.xml

Хост запускает каждый плагин в отдельном процессе-обёртке и логирует необработанные исключения.

При вежливой остановке хоста процессы всех плагинов будут гарантированно завершены. При аварийной остановке хоста (при закрытии окна консоли или при завершении процесса хоста через диспетчер задач) все процессы плагинов завершаются с вероятностью 99,9%. Если процессы плагинов не удалось завершить, то они будут гарантированно завершены при повторном запуске хоста.

В каждой из папок с плагинами может присутствовать файл plugins.xml, в котором указываются файлы сборок, сканируемые на наличие классов плагинов. Если файл отсутствует, содержит синтаксические ошибки или не содержит указаний по файлам сборок (элементов include), то на наличие плагинов сканируются все сборки в папке (это может быть медленно, поэтому такой вариант не рекомендуется).

Пример файла plugins.xml
<?xml version="1.0" encoding="utf-8" ?>
<plugins xmlns="http://syntellect.ru/chronos/include">
<include file="Chronos.Plugins.dll" />
</plugins>

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

Хост использует планировщик Quartz.NET. Он поддерживает Cron, пул потоков, довольно гибок в настройке, масштабируется и тестировался под нагрузкой в Enterprise-решениях. Лицензия Apache 2.0.

Файл app.json в каждой папке позволяет задать конфигурационные параметры плагинов.

При использовании API с расширениями требуется указать файл extensions.xml, в котором задан список сборок с расширениями (extensions.xml могут ссылаться друг на друга, как пример extensions.xml в папке Tessa типовой сборки ссылается на файл extensions.xml в папке extensions).

Пример файла extensions.xml
<?xml version="1.0" encoding="utf-8" ?>
<extensions xmlns="http://syntellect.ru/tessa/include">

  <include file="Tessa.Extensions.Default.Server.dll" serverOnly="true" />
  <include file="Tessa.Extensions.Default.Shared.dll" />

  <include file="Tessa.Extensions.Server.dll" serverOnly="true" />
  <include file="Tessa.Extensions.Shared.dll" />

</extensions>

6.2. Дополнительные параметры командной строки

  • Запуск консольного хоста без поддержки graceful плагинов (см. ниже пункт Graceful плагин):

    Chronos.exe /legacy
  • Справка по параметрам командной строки:

    Chronos.exe /?

6.3. Настройки Chronos.exe.config

  • ServiceName - имя службы, используемое при регистрации. Необходимо поменять, если на одной машине планируется использовать несколько служб Chronos.

  • PluginFolderName - имя папки относительно папки хоста, внутри которой или внутри подпапок которой располагаются плагины. Пример: 'Plugins'.

  • AwaitGracefulStopSeconds - время в секундах (вещественное число), которое хост ожидает, пока все graceful плагины не завершат свою работу. Если время вышло, то процессы всех плагинов будут принудительно завершены. Пример: '1.5'.

6.4. Legacy плагин

Это класс, реализующий интерфейс IPlugin и имеющий атрибут [Plugin].

using Chronos.Contracts

namespace MyPluginNamespace
{
    [Plugin]
    public sealed class MyPlugin : IPlugin
    {
        public void EntryPoint()
        {
            // код плагина
        }
    }
}

С помощью свойств Name, Description и Version можно опционально указать имя, описание и версию плагина, которые будут использоваться хостом в информационных целях (для вывода в логи или в окно консоли).

[Plugin(Name = "My plugin #2", Description = "This plugin can do a lot of things.", Version = 3)]
public sealed class MyPlugin : IPlugin
{
    public void EntryPoint()
    {
        // код плагина
    }
}

При использовании наследования атрибуты плагина не наследуются.

public abstract class MyPluginBase : IPlugin
{
    // этот класс не считается плагином, т.к. у него отсутствует атрибут [Plugin]

    // если бы он присутствовал, то хост не смог бы его запустить, т.к. класс абстрактный

    // если бы класс не был абстрактным, то дочерние классы не являлись бы автоматически плагинами
    // и при наличии у них атрибута [Plugin] могли бы работать по совершенно другому расписанию

    protected abstract void DoWork(string s);

    public void EntryPoint()
    {
        // некоторая начальная инициализация
        this.DoWork("some string");
    }
}

[Plugin]
public sealed class MyPlugin1 : MyPluginBase
{
    // этот плагин будет считаться самым обыкновенным

    public override void DoWork(string s)
    {
        // код плагина
    }
}

[Plugin]
public sealed class MyPlugin2 : MyPluginBase
{
    // хост будет считать, что MyPlugin2 - это ещё один плагин и он никак не связан с MyPlugin1

    public override void DoWork(string s)
    {
        // код плагина
    }
}

6.5. Особенности плагина и его сборки

  • Сборка .DLL плагина:

    • Должна ссылаться на Chronos.Contracts.dll.

    • Может содержать несколько плагинов.

    • Должна располагаться в определённой папке хоста.

  • Плагин запускается хостом в отдельном процессе.

  • Плагин не содержит интерфейс обратного вызова хоста.

  • Плагин может почти что угодно: например, использовать API, предоставляемое Tessa. Оно позволяет:

    • Выполнить некоторую операцию над БД.

    • Учесть специальные блокировки (например, большинство таблиц справочника ролей не может одновременно изменяться несколькими плагинами). Блокировка реализуется через специальное поле Locked в некоторой таблице в БД.

Хост может завершить процесс плагина при своём завершении или перезапуске. Если плагин не успевает сделать какие-то ломающие изменения (например, в базе), то это его задача: восстановить систему в нормальное состояние при повторном запуске.

Желательно, чтобы всё взаимодействие legacy плагина с базой происходило в транзакциях.

Пример: если процесс установки блокировки, изменения БД и удаления блокировки прервать, то плагин должен начать транзакцию, сделать SELECT WITH ( HOLDLOCK ) или WITH ( XLOCK ) на отдельную таблицу с блокировками, наполнить 3 временные таблицы для INSERT, UPDATE, DELETE, далее вызовом трёх команд модифицировать таблицу и завершить транзакцию.
Примечание: блокировка статического справочника ролей не имеет смысла.

Плагин может писать в лог при помощи NLog, но при этом будут использоваться правила логирования, определённые в NLog.config хоста.

6.6. Расписание запуска плагина

Плагин содержит метаданные, через которые он сообщает хосту, когда его запускать. Доступны варианты:

  • Плагин вызывается один раз при запуске хоста. Такой плагин может работать в вечном цикле, делать какую-то работу и периодически засыпать. Плагин также может использовать любой планировщик, например из библиотеки Quartz.NET. Однако, нельзя создавать хост плагинов внутри плагина.

    [Plugin]
    public sealed class OneTimePlugin : IPlugin
    {
        private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
    
        public void EntryPoint()
        {
            // здесь начинается код плагина: он может содержать собственный механизм планирования
    
            // для кода этого метода гарантируется, что он не будет вызван хостом одновременно в разных процессах
    
            // при завершении хоста процесс, в котором выполняется плагин, принудительно завершается;
            // поэтому не следует выполнять в плагине ломающие БД изменения, не заключив их в транзакцию
    
            for (int i = 0; i < 10; i++)
            {
                logger.Trace("Plugin is running...");
                Thread.Sleep(1000);
            }
    
            logger.Trace("Plugin has been stopped and will not run again until host will be restarted.");
        }
    }
  • Плагин вызывается с указанной периодичностью (каждые 10 минут)

    [Plugin(RepeatSecondInterval = 10 * 60, DisallowConcurrency = true)]
    public sealed class PeriodicPlugin : IPlugin
    {
        public void EntryPoint()
        {
            // для кода этого метода гарантируется, что он не будет вызван хостом одновременно в разных процессах,
            // только если установлено свойство DisallowConcurrency = true атрибута [Plugin]
    
            // иначе плагин может выполняться слишком долго и за прошедшее время будет вызван ещё один процесс плагина
    
            // кроме того, ОС может перевести системные часы (например, при автоматической синхронизации времени),
            // и плагин тут же будет вызван повторно, что приведёт к побочным эффектам,
            // если плагин использует некоторый внешний ресурс (например, таблицу в БД)
        }
    }
  • Интервал вызова плагина определяется выражением Cron. Пример: запускать каждую среду в 12:00 и каждую пятницу в 0:00. Если условие вызова нельзя построить одним выражением Cron, то дополнительные выражения могут задаваться с помощью атрибутов PluginTrigger.

    [Plugin(DisallowConcurrency = true)]
    [PluginTrigger(Cron = "0 0 12 * * WED")]
    [PluginTrigger(Cron = "0 0 0 * * FRI")]
    public sealed class CronPlugin : IPlugin
    {
        public void EntryPoint()
        {
            // код плагина
        }
    }
  • Расписание вызова плагина загружается из конфигурационного файла. В данном примере файл pluginConfig.xml должен располагаться в одной папке со сборкой плагина.

Рекомендуется использовать данный вариант для настройки периодического запуска плагина.
[Plugin(ConfigFile = "pluginConfig.xml"))]
public sealed class ConfigFilePlugin : IPlugin
{
    public void EntryPoint()
    {
        // код плагина
    }
}

+ .Пример содержимого файла pluginConfig.xml:

<?xml version="1.0" encoding="UTF-8"?>
<plugin xmlns="http://syntellect.ru/chronos"
        name="Plugin from config"
        description="Info for this plugin is loaded from config file."
        version="5"
        disallowConcurrency="true"
        disabled="true">

    <!-- disabled="true" определяет, что плагин не будет использоваться (можно временно его отключить через конфиг) -->

    <!-- Запускать каждую среду в 12:00 -->
    <trigger cron="0 0 12 ? * WED" />

    <!--
        Дополнительно запускать с интервалом в 36 часов.
        Поскольку начало отсчёта не определено, лучше вместо этого использовать выражение Cron.
    -->
    <trigger repeatSeconds="129600" />
</plugin>

Пример файла pluginConfig.xml, не содержащего информацию о плагине (при отсутствии другой информации из атрибутов Plugin и PluginTrigger плагин будет вызван один раз, как будто бы конфигурационного файла не было):

<?xml version="1.0" encoding="UTF-8"?>
<plugin xmlns="http://syntellect.ru/chronos" />

В каждом из атрибутов Plugin и PluginTrigger можно указывать не более одного из свойств RepeatSecondPeriod, Cron, ConfigFile. Все другие комбинации атрибутов и их свойств допустимы. Например, частично информацию о плагине можно задать через атрибуты, а другую часть получить из одного или нескольких конфигурационных файлов.

Описание синтаксиса CRON, который используется в расписании плагинов, а также в динамических ролях и генераторах метаролей, можно найти здесь: http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html

6.7. Примеры плагинов

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

  • Пересчёт таблиц замещений в статических ролях. Плагин вызывается периодически.

  • ADSync: синхронизация сотрудников и ролей с Active Directory.

    • Плагин вызывается периодически по выражениям Cron (для синхронизации по требованию должен напрямую вызываться соответствующий метод API).

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

6.8. Graceful плагин

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

using Chronos.Contracts;

[Plugin]
class MyGracefulPlugin : IPlugin, ISupportGracefulStop
{
    public void EntryPoint() { /* do work */ System.Threading.Thread.Sleep(5000); }

    public void Stop(IGracefulStopToken token)
    {
        // этот метод должен инициировать остановку метода EntryPoint и дождаться его завершения
        token.WaitUntilEntryPointFinished();
    }
}
  • ISupportGracefulStop - дополнительный интерфейс плагина, поддерживающего вежливую остановку. Плагин, реализующий данный интерфейс, должен также реализовывать IPlugin.

  • Stop - метод, вызываемый хостом внутри процесса плагина при вежливой остановке плагина.

  • Параметр token - токен, позволяющий определить состояние плагина из метода его вежливой остановки. Его метод WaitUntilEntryPointFinished дожидается завершения работы метода EntryPoint плагина. СвойствоEntryPointFinished позволяет проверить, завершён ли уже метод EntryPoint. Свойство EntryPointThread содержит ссылку на поток, в котором выполняется метод EntryPoint.

Метод Stop должен максимально быстро завершить выполнение плагина, но не завершать свою работу до тех пор, пока потоки, с которыми работает плагин, не будут завершены.

Хост вызовет метод Stop в потоке, отличном от потока, в котором выполняется метод EntryPoint. Если метод Stop неожиданно прервётся, то исключение будет добавлено в лог. Если после его завершения метод EntryPointвсё ещё выполняется, то поток, в котором он выполняется, будет прерван через Thread.Abort. Поэтому, при наличии проблем завершения, метод EntryPoint должен завершать все созданные плагином потоки, не являющиеся Background-потоками, в блоке catch(ThreadAbortException).

Если метод Stop вызовет Thread.Abort для потока, в котором выполняется EntryPoint, то он будет завершён вместе с этим потоком. Аналогичное поведение происходит при завершении метода Stop до того, как EntryPointбыл завершён. Однако, плагин при этом считается корректно завершённым.

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

  • Если метод Stop не успел выполнить все действия за заданное время, то процесс плагина будет принудительно остановлен хостом одновременно с остановкой других ещё работающих процессов legacy и graceful плагинов.

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

  • Поддержку graceful плагинов можно отключить в консольном хосте, запустив его с ключом /legacy. При этом graceful плагины даже при вводе команды stop будут завершены принудительно наравне с legacy плагинами. Это может быть удобно при отладке.

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

6.9. Пример graceful плагина

using System.Threading;
using Chronos.Contracts;

[Plugin(Name = "Graceful plugin", RepeatSecondInterval = 10)]
class MyGracefulPlugin : IPlugin, ISupportGracefulStop
{
    private volatile bool stop;

    public void EntryPoint()
    {
        while (!this.stop)
        {
            Thread.Sleep(100);    // do work
        }
    }

    public void Stop(IGracefulStopToken token)
    {
        this.stop = true;

        // чтобы подождать завершения, не выполняя дополнительных действий:
        // token.WaitUntilEntryPointFinished();

        while (!token.EntryPointFinished)
        {
            Thread.Sleep(50);    // пока EntryPoint останавливается - что-то делаем
        }
    }
}

6.10. Конфигурирование плагина

6.10.1. Использование конфигурации из файла app.json

Chronos позволяет хранить настройки для плагинов в файле app.json.

Например, нам необходимы несколько настроек для плагина TestPlugin: TestString - строковое значение, TestInt - числовое значение.

Для этого необходимо добавить в блок Settings файла app.json:

Внимательно следите за синтаксисом! Json требует экранирования '\': вместо '\' необходимо указывать '\\'. Так же требуются запятые при перечислении элементов настроек, если синтаксис будет некорректным, то это приведет к ошибке при запуске любых плагинов Chronos.
{
	"Settings": {
		"TestPlugin.TestString": "test",
		"TestPlugin.TestInt": 1
	}
}

Получить эти настройки изнутри плагина можно так:

logger.Info("TestString = '{0}'", ConfigurationManager.Settings.TryGet("TestPlugin.TestString", string.Empty));
logger.Info("TestInt = '{0}'", ConfigurationManager.Settings.TryGet("TestPlugin.TestInt", 0));

6.11. Параметры логирования NLog для плагина

Информация из этого раздела актуальна только для Chronos версии 1.3 или более поздней.

Если плагину требуются отличные от хоста параметры логирования, и он использует NLog, то определить уникальные для сборки с плагином параметры можно в её файле NLog.config, который следует расположить в одной папке со сборкой плагина. В качестве {basedir} будет использоваться папка с Chronos.exe, а не папка, в которой лежит NLog.config.

Пример файла NLog.config с конфигурацией NLog:

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

  <targets async="true">
    <target name="file" xsi:type="File" encoding="utf-8" writeBom="true" fileName="${basedir}\Plugins\MyPlugins\log.txt" />
    <target name="queries" xsi:type="File" encoding="utf-8" writeBom="true" fileName="${basedir}/queries.txt" layout="--${longdate}${newline}${message}${newline}GO${newline}" />
    <target name="process" xsi:type="File" encoding="utf-8" writeBom="true" fileName="${basedir}/process.txt" layout="${longdate}${newline}${message}${newline}" />
    <target name="null" xsi:type="Null" formatMessage="false"  />
  </targets>

  <rules>
    <logger name="SqlQueries" minlevel="Error" writeTo="queries" final="true" />
    <logger name="SqlQueries" minlevel="Trace" writeTo="null" final="true" />
    <logger name="Process" minlevel="Error" writeTo="process" final="true" />
    <logger name="Process" minlevel="Trace" writeTo="null" final="true" />
    <logger name="Configuration" minlevel="Info" writeTo="file" final="true" />
    <logger name="Configuration" minlevel="Trace" writeTo="null" final="true" />
    <logger name="Quartz.*" minlevel="Error" writeTo="file" final="true" />
    <logger name="Quartz.*" minlevel="Trace" writeTo="null" final="true" />
    <logger name="*" minlevel="Info" writeTo="file" />
  </rules>

</nlog>

6.12. Запуск хоста из Visual Studio

Visual Studio 2010 (или более новая) запускает все приложения, используя помощник по совместимости (для Windows Vista и более новых, x86 и x64). Поэтому запущенный хост Chronos.exe не будет использовать WinAPI Job для надёжного завершения дочерних процессов. Скорее всего процессы всё равно будут завершены, но вероятность их успешного завершения снижается.

Такое поведение наблюдается как при запуске с отладчиком F5, так и без него Ctrl+F5.

Чтобы это исправить, нужно:

  1. Открыть редактор реестра

  2. Перейти в HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Compatibility Assistant

  3. В контекстном меню нажать Добавить → Мультистроковый параметр

  4. Назвать параметр ExecutablesToExclude

  5. Задать значение параметра как пути к файлам devenv.exe и VSLauncher.exe на отдельных строках без кавычек. Например:

    Для VisualStudio 2010 на Windows 7 x86
    C:\Program Files\Microsoft Visual Studio 10.0\Common7\IDE\devenv.exe
    C:\Program Files\Common Files\microsoft shared\MSEnv\VSLauncher.exe
    Для VisualStudio 2010 на Windows 7 x64
    C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\devenv.exe
    C:\Program Files (x86)\Common Files\microsoft shared\MSEnv\VSLauncher.exe

Для запуска хоста без Visual Studio ничего из этого раздела делать не нужно.

7. Разработка бизнес-процессов

7.1. Создание бизнес-процесса посредством Workflow API

Workflow API позволяет быстро и гибко создавать бизнес-процессы (БП), описывая их задания и переходы между ними, а также выделяя подпроцессы. Workflow API работает как специальное серверное расширение CardStoreExtension.BeforeCommitTransaction, при изменении карточки перед завершением транзакции. При этом активна транзакция на изменение сохраняемой карточки, поэтому при возникновении любых проблем, связанных с невозможностью выполнить какое-либо действие, произойдёт откат транзакции SQL и всех связанных изменений.

Рассмотрим основные понятия:

  • Задание – это те задания карточки, которые используются для формирования БП. Список типов заданий, поддерживаемых БП, заранее задаётся в коде расширения. У заданий есть один или несколько вариантов завершения, при выборе которых выполняется переход (см. ниже). Также при создании задания указываются параметры (произвольные данные, сериализуемые в BSON)

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

  • Подпроцесс – это множество переходов, объединённое общей целью и имеющее список параметров (произвольных данных, сериализуемых в BSON). Подпроцесс идентифицируется уникальным идентификатором и текстовой строкой типа подпроцесса. Примером подпроцесса может служить параллельное (или последовательное) согласование по заданному списку ролей. Параметры подпроцесса могут изменяться в процессе выполнения его переходов (например, если в них содержится список ролей для последовательного согласования, то после завершения задания на каждую из ролей эта роль удаляется из списка и параметры пересохраняются). Параметры также могут содержать номера переходов родительского подпроцесса, которые будут выполнены по завершении этого подпроцесса. Подпроцесс имеет методы старта StartProcess и завершения StopProcess, задача которых – вызвать переходы.

  • Счётчик – это целое число, которое инициализируется некоторым положительным значением, и далее значение которого уменьшается вызовом метода DecrementCounter. Если значение стало равным нулю или меньше, то счётчик удаляется и код, вызвавший метод DecrementCounter, может выполнить некоторое действие. Посредством счётчика удобно реализовывать в БП блок "И", который выполняет следующее за ним действие (переход) только в том случае, если было завершено определённое количество заданий в параллельном согласовании. Счётчик идентифицируется номером, уникальным для подпроцесса.

Рассмотрим следующий бизнес-процесс. Он состоит из подпроцесса 1 и глобального подпроцесса (т.к. весь процесс представляется в то же время как подпроцесс). В процессе используются задания двух типов.

Посмотреть данный бизнес-процесс в работе, вы можете в Tessa Client, создав (требуются права администратора) карточку типа "Автомобиль" и нажав на левой панели карточки кнопку "Тестовое согласование". Исходный код процесса поставляется вместе со всеми остальными расширениями типового решения (Extensions\Tessa.Extensions.Default.Client\Tiles\TestProcessTileExtension.cs и Extensions\Tessa.Extensions.Default.Server\Workflow\TestProcess\).
  • Тип1: задание с двумя вариантами завершения "Вариант А" и "Вариант Б". Это задание будет использоваться в разных частях процесса — как часть подпроцесса 1 согласования, а также как отдельное задание, причем в разных местах действия, выполняемые по вариантам завершения, будут разными.

    image69
  • Тип2: задание с одним вариантом завершения "Вариант В\Принять".

    image70

Подпроцесс 1 отправляет три задания типа 1 параллельно. По завершении каждого из заданий он выполняет разные действия для каждого варианта завершения. После завершения всех заданий также выполняются какие-то действия, затем происходит выход из подпроцесса.

На схеме процесса цифрами (иногда в скобках) указаны номера переходов. Все переходы внутри подпроцесса локальны и их номера указаны с префиксом ЛП. В схеме используются два счетчика И с номерами Счетчик1 и Счетчик2. Номера счетчиков должны быть уникальны только в пределах своего подпроцесса. В схеме номера приведены разные для удобства объяснения.

image34

Дальше опишем переходы и обработка завершения заданий.

Обработка завершения заданий (реализуется в расширении):
  • Тип1: при завершении из параметров задания определяются номера переходов, указанные при отправке задания и выполняется переход, соответствующий вариантам А или Б.

  • Тип2: при завершении берется из параметров задания и выполняется переход.

Локальные переходы (далее ЛП) внутри подпроцесса 1:

  • Старт процесса: инициализирует новый счетчик Счетчик* нулем и трижды запускает локальный переход 1.

  • ЛП 1: Запускает задание Тип1, настраивая его так, чтобы при завершении по варианту А — Выполнится ЛП 2, а при завершении по варианту Б — выполнится ЛП 3. Также увеличивает Счетчик1 на 1.

  • ЛП2: выполняет некоторые действия (завершилось задание по варианту А) и выполняет ЛП 4.

  • ЛП3: выполняет некоторые действия (завершилось задание по варианту Б) и выполняет ЛП 4.

  • ЛП4: уменьшает локальный счетчик Счетчик2, и, если он = 0, запускает ЛП 5.

  • ЛП5: что-то делаем (обрабатываем результат подпроцесса) и выполняем указанный в настройках глобальный переход. В варианте как на схеме это будет глобальный переход 4.

Глобальные переходы:
  • Старт процесса: инициализирует новый глобальный счетчик И числом 2 и выполняет переходы номер 1 и 2.

  • 1: Отправляет задание типа 1, настраивая его так, чтобы при завершении по варианту А выполнился переход 3, а по варианту Б - 4.

  • 2: Запускает подпроцесс 1, настраивая его так, чтобы при завершении выполнился переход 4.

  • 3: Отправляет задание Тип2, настраивая его так, чтобы при завершении выполнился глобальный переход 4.

  • 4: Уменьшает счетчик Счетчик1, если он = 0, выполняет переход 5.

  • 5: Запускает подпроцесс 1, настраивая его так, чтобы по завершении выполнился переход 6.

  • 6: Процесс завершен.

Реализация процесса состоит из следующих шагов:

  1. Реализовать IWorkflowManager или использовать стандартный класс WorkflowManager. Для обеспечения доступа к карточке-сателлиту при наличии таковой рекомендуется унаследоваться от WorkflowManager и добавить туда свойство Satellite, содержащее загруженную карточку-сателлит. Этот класс:

    • Обеспечивает доступ к контексту бизнес-процесса, содержащему:

      • запрос на сохранение карточки Request;

      • следующий запрос на сохранение NextRequest, в котором будут при необходимости добавлены задания;

      • результат валидации ValidationResult;

      • метаинформация по карточкам CardMetadata и др.,

    • Предоставляет средства к чтению и изменению информации по подпроцессу (включая параметры), по активным заданиям (включая параметры) и по счётчикам (включая декремент счётчика и проверку на равенство нулю).

  2. Реализовать IWorkflowWorker или унаследоваться от стандартного WorkflowTaskWorker<TManager> (предоставляющий стандартные возможности, где вместо TManager – реализация из предыдущего шага. Через свойство Manager можно получить доступ к IWorkflowManager’у, у которого запросить информацию по карточке, создать задание и др. При наследовании следует переопределить методы:

    • StartProcessCore – выполняет действия при запуске подпроцесса. Т.к. весь процесс также является подпроцессом, то здесь можно выполнить подготовительные действия при запуске процесса, например, изменить состояние карточки на "На согласование".

    • StopProcessCore – выполняет действия при завершении подпроцесса, в т.ч. при завершении всего процесса. В параметрах подпроцесса могут быть получены переходы родительского подпроцесса, которые следует выполнить при завершении текущего подпроцесса.

    • CompleteTaskCore – выполняет действия при завершении задания. По типу задания, варианту завершения и параметрам задания в процессе можно определить выполняемые действия. Например, для каждого из вариантов завершения "Принять" и "Отказать" в параметрах задания могут быть указаны номера переходов в текущем подпроцессе, которые требуется выполнить.

    • RenderStepCore – выполняет переход с заданным номером для указанного подпроцесса. Например, переход может инициализировать счётчик через свойство Manager и выслать несколько заданий для параллельного согласования.

  3. Реализовать расширение на сохранение карточки, унаследовав его от WorkflowStoreExtension (если бизнес-процесс может применяться к карточками, добавленным в типовое решение, то рекомендуется унаследовать расширение от KrWorkflowStoreExtension). Это расширение связывает сохранение карточки с работой бизнес-процесса посредством реализации IWorkflowWorker, созданной на предыдущем шаге. Расширение определяет методы:

    • TaskIsAllowed – метод, определяющий, принадлежит ли это задание к бизнес-процессу и может ли его обработать метод IWorkflowWorker.CompleteTask. Принадлежность обычно определяют по идентификатору или имени типа задания.

    • CanStartProcess – метод, возвращающий признак того, можно ли выполнить старт бизнес-процесса с заданным именем. Это требуется для того, чтобы на одной и той же карточке можно было запустить несколько различных бизнес-процессов, таких как процесс согласования и резолюции, а также для того, чтобы обеспечить несколько возможных входов в один и тот же бизнес-процесс.

    • StartProcess – метод, выполняющий запуск процесса согласования для заданной реализации IWorkflowWorker.

    • CreateWorker – создаёт реализацию IWorkflowWorker по заданной реализации IWorkflowManager.

  4. Реализовать расширения плиток TileExtension для запуска процесса, отзыва процесса и др.

Для реализации бизнес-процесса, описанного выше, можно обойтись без карточки-сателлита, и добавить в карточку платформенные коллекционные секции WorkflowCounters, WorkflowProcesses и WorkflowTasks, используемые стандартной реализацией WorkflowManager’а для хранения информации по счётчикам, подпроцессам и заданиям. Эти секции добавляются в карточку-сателлит в более сложных случаях, когда в ходе согласования требуется хранить дополнительные поля, такие как комментарий инициатора, состояние процесса и др., причём такие поля не должны быть частью карточки для того, чтобы после завершения задания не изменялась версия карточки (для обеспечения параллельности согласования).

Поскольку сателлита и дополнительных данных нет, используем стандартную реализацию IWorkflowManager – класс WorkflowManager, и на его основе реализуем IWorkflowWorker – наследник WorkfowTaskWorker<WorkflowManager>.

using System;
using System.Collections.Generic;
using Tessa.Cards;
using Tessa.Cards.Workflow;
using Tessa.Extensions.Default.Shared;
using Tessa.Extensions.Default.Shared.Workflow.TestProcess;
using Tessa.Platform.Storage;
using Tessa.Platform.Validation;

namespace Tessa.Extensions.Default.Server.Workflow.TestProcess
{
    /// <summary>
    /// Класс, реализующий логику бизнес-процесса TestProcess.
    /// </summary>
    public sealed class TestWorkflowWorker :
        WorkflowTaskWorker<IWorkflowManager>
    {
        #region Constructors

        public TestWorkflowWorker(
            IWorkflowManager manager,
            ICardRepository cardRepositoryToCreateTasks)
            : base(manager, cardRepositoryToCreateTasks)
        {
        }

        #endregion

        #region Private Task Helpers

        /// <summary>
        /// Отправляет задание типа Тип1 с указанием переходов,
        /// выполняемых при завершении задания по каждому из вариантов завершения.
        /// </summary>
        /// <param name="completionTransitionA">Номер перехода, выполняемого при выборе варианта завершения А.</param>
        /// <param name="completionTransitionB">Номер перехода, выполняемого при выборе варианта завершения Б.</param>
        /// <param name="processInfo">Подпроцесс, в котором отправляется задание.</param>
        /// <param name="digest">Краткая информация по заданию, которую увидит пользователь.</param>
        /// <param name="roleID">Идентификатор роли, на которую отправляется задание.</param>
        /// <param name="roleName">Имя роли, на которую отправляется задание.</param>
        private void SendTask1(
            int completionTransitionA,
            int completionTransitionB,
            IWorkflowProcessInfo processInfo,
            string digest,
            Guid roleID,
            string roleName)
        {
            this.SendTask(
                DefaultTaskTypes.TestTask1TypeID,
                processInfo,
                digest,
                roleID,
                roleName,
                new Dictionary<string, object>(StringComparer.Ordinal)
                {
                    { "A", completionTransitionA },
                    { "B", completionTransitionB },
                });
        }


        /// <summary>
        /// Отправляет задание типа Тип1 на роль текущего пользователя с указанием переходов,
        /// выполняемых при завершении задания по каждому из вариантов завершения.
        /// </summary>
        /// <param name="completionTransitionA">Номер перехода, выполняемого при выборе варианта завершения А.</param>
        /// <param name="completionTransitionB">Номер перехода, выполняемого при выборе варианта завершения Б.</param>
        /// <param name="processInfo">Подпроцесс, в котором отправляется задание.</param>
        /// <param name="digest">Краткая информация по заданию, которую увидит пользователь.</param>
        private void SendTask1(
            int completionTransitionA,
            int completionTransitionB,
            IWorkflowProcessInfo processInfo,
            string digest)
        {
            this.SendTask1(
                completionTransitionA,
                completionTransitionB,
                processInfo,
                digest,
                this.Manager.Session.User.ID,
                this.Manager.Session.User.Name);
        }


        /// <summary>
        /// Отправляет задание типа Тип2 с указанием перехода, выполняемого при завершении задания.
        /// </summary>
        /// <param name="completionTransition">Номер перехода, выполняемого при завершении задания.</param>
        /// <param name="processInfo">Подпроцесс, в котором отправляется задание.</param>
        /// <param name="digest">Краткая информация по заданию, которую увидит пользователь.</param>
        /// <param name="roleID">Идентификатор роли, на которую отправляется задание.</param>
        /// <param name="roleName">Имя роли, на которую отправляется задание.</param>
        private void SendTask2(
            int completionTransition,
            IWorkflowProcessInfo processInfo,
            string digest,
            Guid roleID,
            string roleName)
        {
            this.SendTask(
                DefaultTaskTypes.TestTask2TypeID,
                processInfo,
                digest,
                roleID,
                roleName,
                new Dictionary<string, object>(StringComparer.Ordinal)
                {
                    { "Completion", completionTransition },
                });
        }


        /// <summary>
        /// Отправляет задание типа Тип2 на роль текущего пользователя с указанием перехода,
        /// выполняемого при завершении задания.
        /// </summary>
        /// <param name="completionTransition">Номер перехода, выполняемого при завершении задания.</param>
        /// <param name="processInfo">Подпроцесс, в котором отправляется задание.</param>
        /// <param name="digest">Краткая информация по заданию, которую увидит пользователь.</param>
        private void SendTask2(
            int completionTransition,
            IWorkflowProcessInfo processInfo,
            string digest)
        {
            this.SendTask2(
                completionTransition,
                processInfo,
                digest,
                this.Manager.Session.User.ID,
                this.Manager.Session.User.Name);
        }

        #endregion

        #region Base Overrides

        /// <summary>
        /// Выполняет действия при запуске подпроцесса.
        /// </summary>
        /// <param name="processInfo">Информация по запускаемому подпроцессу.</param>
        protected override void StartProcessCore(IWorkflowProcessInfo processInfo)
        {
            switch (processInfo.ProcessTypeName)
            {
                case TestProcessHelper.MainSubProcess:
                    this.Manager.InitCounter(1, processInfo, initialValue: 2);
                    this.RenderStep(1, processInfo);
                    this.RenderStep(2, processInfo);
                    break;

                case TestProcessHelper.SubProcess1:
                    this.RenderStep(1, processInfo);
                    break;

                default:
                    throw new ArgumentOutOfRangeException("processInfo.ProcessTypeName");
            }
        }


        /// <summary>
        /// Выполняет действия при завершении подпроцесса.
        /// </summary>
        /// <param name="processInfo">Информация по завершаемому подпроцессу.</param>
        protected override void StopProcessCore(IWorkflowProcessInfo processInfo)
        {
            switch (processInfo.ProcessTypeName)
            {
                case TestProcessHelper.MainSubProcess:
                    // завершился бизнес-процесс, здесь можно перевести карточку в состоянию "Согласовано"
                    break;

                case TestProcessHelper.SubProcess1:
                    this.StopSubProcessWithCompletion(processInfo);
                    break;

                default:
                    throw new ArgumentOutOfRangeException("processInfo.ProcessTypeName");
            }
        }


        /// <summary>
        /// Выполняет действия при завершении задания.
        /// </summary>
        /// <param name="taskInfo">Информация по завершаемому заданию.</param>
        protected override void CompleteTaskCore(IWorkflowTaskInfo taskInfo)
        {
            Guid typeID = taskInfo.Task.TypeID;
            if (typeID == DefaultTaskTypes.TestTask1TypeID)
            {
                Guid? optionID = taskInfo.Task.OptionID;
                if (optionID == DefaultCompletionOptions.OptionA)
                {
                    // Тип1, Вариант А
                    int transitionNumber = taskInfo.TaskParameters.Get<int>("A");
                    this.RenderStep(transitionNumber, taskInfo);
                }
                else if (optionID == DefaultCompletionOptions.OptionB)
                {
                    // Тип1, Вариант Б
                    int transitionNumber = taskInfo.TaskParameters.Get<int>("B");
                    this.RenderStep(transitionNumber, taskInfo);
                }
            }
            else if (typeID == DefaultTaskTypes.TestTask2TypeID)
            {
                // Тип2
                int transitionNumber = taskInfo.TaskParameters.Get<int>("Completion");
                this.RenderStep(transitionNumber, taskInfo);
            }
        }


        /// <summary>
        /// Обрабатывает внешний сигнал. Возвращает признак того, что сигнал известен и был обработан.
        /// </summary>
        /// <param name="signalInfo">Информация по сигналу и подпроцессу, для которого выполняется сигнал.</param>
        /// <returns>Признак того, что сигнал известен и был обработан.</returns>
        protected override bool ProcessSignalCore(IWorkflowSignalInfo signalInfo)
        {
            if (signalInfo.Signal.Type != WorkflowSignalTypes.Default)
            {
                return base.ProcessSignalCore(signalInfo);
            }

            switch (signalInfo.ProcessTypeName)
            {
                case TestProcessHelper.MainSubProcess:
                    switch (signalInfo.Signal.Name)
                    {
                        case TestProcessHelper.TestSignal:
                            this.RenderStepCore(7, signalInfo);
                            return true;
                    }
                    break;
            }

            return false;
        }


        /// <summary>
        /// Выполняет переход.
        /// </summary>
        /// <param name="transitionNumber">Номер перехода.</param>
        /// <param name="processInfo">Информация по подпроцессу, в котором выполняется переход.</param>
        protected override void RenderStepCore(int transitionNumber, IWorkflowProcessInfo processInfo)
        {
            switch (processInfo.ProcessTypeName)
            {
                case TestProcessHelper.MainSubProcess:
                    switch (transitionNumber)
                    {
                        case 1:
                            this.SendTask1(3, 4, processInfo, "Первое задание в основном процессе");
                            break;

                        case 2:
                            this.StartSubProcessWithCompletion(TestProcessHelper.SubProcess1, 4, processInfo);
                            break;

                        case 3:
                            this.SendTask2(4, processInfo, "Второе необязательное задание в основном процессе");
                            break;

                        case 4:
                            if (this.Manager.DecrementCounter(1, processInfo) == WorkflowCounterState.Finished)
                            {
                                this.RenderStep(5, processInfo);
                            }
                            break;

                        case 5:
                            this.StartSubProcessWithCompletion(TestProcessHelper.SubProcess1, 6, processInfo);
                            break;

                        case 6:
                            this.StopProcess(processInfo);
                            break;

                        case 7:
                            this.Manager.ValidationResult.AddInfo(this, "Тестовый сигнал обработан");
                            break;

                        default:
                            throw new ArgumentOutOfRangeException("transitionNumber");
                    }
                    break;

                case TestProcessHelper.SubProcess1:
                    switch (transitionNumber)
                    {
                        case 1:
                            this.Manager.InitCounter(1, processInfo, 3);
                            for (int i = 0; i < 3; i++)
                            {
                                this.SendTask1(2, 3, processInfo, "Одно из трёх заданий в подпроцессе");
                            }
                            break;

                        case 2:
                            this.RenderStep(4, processInfo);
                            break;

                        case 3:
                            this.RenderStep(4, processInfo);
                            break;

                        case 4:
                            if (this.Manager.DecrementCounter(1, processInfo) == WorkflowCounterState.Finished)
                            {
                                this.RenderStep(5, processInfo);
                            }
                            break;

                        case 5:
                            this.StopProcess(processInfo);
                            break;

                        default:
                            throw new ArgumentOutOfRangeException("transitionNumber");
                    }
                    break;

                default:
                    throw new ArgumentOutOfRangeException("processInfo.ProcessTypeName");
            }
        }

        #endregion
    }
}

Расширение на сохранение карточки следует унаследовать от класса WorkflowStoreExtension, переопределив в нём несколько методов (для определения собственного WorkflowManager’а потребуется дополнительно переопределить метод CreateManager).

using System;
using Tessa.Cards;
using Tessa.Cards.ComponentModel;
using Tessa.Cards.Extensions;
using Tessa.Cards.Workflow;
using Tessa.Extensions.Default.Server.Workflow.KrProcess;
using Tessa.Extensions.Default.Shared;
using Tessa.Extensions.Default.Shared.Workflow.TestProcess;
using Unity;

namespace Tessa.Extensions.Default.Server.Workflow.TestProcess
{
    public sealed class TestWorkflowStoreExtension :
        KrWorkflowStoreExtension
    {
        #region Constructors

        public TestWorkflowStoreExtension(
            IKrTokenProvider krTokenProvider,
            [Dependency(nameof(CardRepositoryNames.DefaultWithoutTransaction))] ICardRepository cardRepositoryToCreateNextRequest,
            [Dependency(nameof(CardRepositoryNames.ExtendedWithoutTransaction))] ICardRepository cardRepositoryToStoreNextRequest,
            [Dependency(nameof(CardRepositoryNames.ExtendedWithoutTransaction))] ICardRepository cardRepositoryToCreateTasks,
            ICardTaskHistoryManager taskHistoryManager,
            ICardGetStrategy cardGetStrategy,
            IWorkflowQueueProcessor workflowQueueProcessor)
            : base(
                krTokenProvider,
                cardRepositoryToCreateNextRequest,
                cardRepositoryToStoreNextRequest,
                cardRepositoryToCreateTasks,
                taskHistoryManager,
                cardGetStrategy,
                workflowQueueProcessor)
        {
        }

        #endregion

        #region Base Overrides

        protected override bool TaskIsAllowed(CardTask task, ICardStoreExtensionContext context)
        {
            Guid taskTypeID = task.TypeID;
            return taskTypeID == DefaultTaskTypes.TestTask1TypeID
                || taskTypeID == DefaultTaskTypes.TestTask2TypeID;
        }

        protected override bool CanHandleQueueItem(WorkflowQueueItem queueItem, ICardStoreExtensionContext context) =>
            TestProcessHelper.MainSubProcess == queueItem.Signal.ProcessTypeName;

        protected override bool CanStartProcess(Guid? processID, string processName, ICardStoreExtensionContext context)
        {
            switch (processName)
            {
                case TestProcessHelper.ProcessName:
                    return true;

                default:
                    return false;
            }
        }

        protected override void StartProcess(Guid? processID, string processName, IWorkflowWorker workflowWorker)
        {
            switch (processName)
            {
                case TestProcessHelper.ProcessName:
                    workflowWorker.StartProcess(TestProcessHelper.MainSubProcess, newProcessID: processID);
                    break;

                default:
                    throw new ArgumentOutOfRangeException("processName");
            }
        }

        protected override IWorkflowWorker CreateWorker(IWorkflowManager workflowManager)
        {
            return new TestWorkflowWorker(workflowManager, this.CardRepositoryToCreateTasks);
        }

        #endregion
    }
}

В конструкторе расширения используются параметры:

  • cardRepositoryToCreateNextRequest – репозиторий карточек, используемый для создания запроса на дополнительное сохранение IWorkflowManager.NextRequest, в котором будут создаваться задания и изменяться поля карточки вследствие переходов в бизнес-процессе.

  • cardRepositoryToStoreNextRequest – репозиторий карточек, используемый для сохранения запроса IWorkflowManager.NextRequest, если такое сохранение требуется в соответствии со значением IWorkflowManager.NextRequestPending, а также в соответствии с наличием изменений внутри карточки NextRequest.Card. Необходимо использовать репозиторий без транзакции для этого параметра.

  • cardRepositoryToCreateTasks – репозиторий карточек, используемый для создания карточек заданий, которые затем помещаются в пакет карточки NextRequest.Card.

Расширение регистрируется в Unity следующим образом для типа карточек "Car", при этом для первого параметра (cardRepositoryToCreateNextRequest) в конструкторе передаётся реализация репозитория карточек без транзакции и без расширений (чтобы не выполнялись такие расширения, как резервирование номера), а для остальных – без транзакции и с расширениями:

[Registrator]
public sealed class Registrator : RegistratorBase
{
    public override void RegisterUnity()
    {
        this.UnityContainer
            .RegisterType<TestWorkflowStoreExtension>(new PerResolveLifetimeManager())
            ;
    }


    public override void RegisterExtensions(IExtensionContainer extensionContainer)
    {
        extensionContainer
            .RegisterExtension<ICardStoreExtension, TestWorkflowStoreExtension>(x => x
                .WithOrder(ExtensionStage.AfterPlatform)
                .WithUnity(this.UnityContainer)
                .WhenCardTypes(DefaultCardTypes.CarTypeID))
            ;
    }
}

Для отображения плитки, запускающей согласование, в типе карточек "Car" используется расширение, сохраняющее карточку со специальным флагом, указываемым в методе SetStartingProcessName:

using System;
using System.Collections.Generic;
using Tessa.Cards;
using Tessa.Cards.Workflow;
using Tessa.Extensions.Default.Shared.Workflow.TestProcess;
using Tessa.Platform.Collections;
using Tessa.UI;
using Tessa.UI.Cards;
using Tessa.UI.Tiles;
using Tessa.UI.Tiles.Extensions;

namespace Tessa.Extensions.Default.Client.Tiles
{
    /// <summary>
    /// Расширения для бизнес-процесса TestProcess.
    /// </summary>
    public sealed class TestProcessTileExtension :
        TileExtension
    {
        #region Evaluating Handlers

        private static void EnableOnTestTypesAndNoProcesses(object sender, TileEvaluationEventArgs e)
        {
            ICardEditorModel editor = e.CurrentTile.Context.CardEditor;
            ICardModel model;

            e.SetIsEnabledWithCollapsing(
                e.CurrentTile,
                editor != null
                && (model = editor.CardModel) != null
                && model.CardType.Flags.Has(CardTypeFlags.AllowTasks)
                && model.CardType.Name == "Car"
                && model.Card.StoreMode == CardStoreMode.Update
                && model.Card.Sections["WorkflowProcesses"].Rows.Count == 0);
        }


        private static void EnableOnTestTypesAndHasProcesses(object sender, TileEvaluationEventArgs e)
        {
            ICardEditorModel editor = e.CurrentTile.Context.CardEditor;
            ICardModel model;

            e.SetIsEnabledWithCollapsing(
                e.CurrentTile,
                editor != null
                && (model = editor.CardModel) != null
                && model.CardType.Flags.Has(CardTypeFlags.AllowTasks)
                && model.CardType.Name == "Car"
                && model.Card.StoreMode == CardStoreMode.Update
                && model.Card.Sections["WorkflowProcesses"].Rows.Count > 0);
        }

        #endregion

        #region Command Actions

        private static async void StartTestProcessActionAsync(object parameters)
        {
            IUIContext context = UIContext.Current;
            ICardEditorModel editor = context.CardEditor;

            if (editor == null || editor.CardModel == null)
            {
                return;
            }

            await editor.SaveCardAsync(
                context,
                new Dictionary<string, object>()
                    .SetStartingProcessName(TestProcessHelper.ProcessName));
        }


        private static async void SendTestSignalActionAsync(object parameters)
        {
            IUIContext context = UIContext.Current;
            ICardEditorModel editor = context.CardEditor;

            if (editor == null || editor.CardModel == null)
            {
                return;
            }

            await editor.SaveCardAsync(
                context,
                request: new CardSavingRequest(cardModifierAction:
                    card =>
                    {
                        WorkflowQueue queue = card.GetWorkflowQueue();

                        queue.AddSignal(
                            TestProcessHelper.MainSubProcess,
                            TestProcessHelper.TestSignal);
                    }));
        }

        #endregion

        #region Base Overrides

        public override void InitializingGlobal(ITileGlobalExtensionContext context)
        {
            ITileContextSource contextSource = context.Workspace.LeftPanel;

            context.Workspace.LeftPanel.Tiles.AddRange(
                new Tile(
                    "StartTestProcess",
                    TileHelper.SplitCaption("$KrTest_TestApprovalTile"),
                    context.Icons.Get("Thin127"),
                    contextSource,
                    new DelegateCommand(StartTestProcessActionAsync),
                    TileGroups.Cards,
                    order: 6,
                    evaluating: EnableOnTestTypesAndNoProcesses),

                new Tile(
                    "SendTestSignal",
                    TileHelper.SplitCaption("$KrTest_TestSignalTile"),
                    context.Icons.Get("Thin229"),
                    contextSource,
                    new DelegateCommand(SendTestSignalActionAsync),
                    TileGroups.Cards,
                    order: 6,
                    evaluating: EnableOnTestTypesAndHasProcesses))
                ;
        }

        #endregion
    }
}

Регистрация расширения выполняется на клиенте:

extensionContainer
    .RegisterExtension<ITileGlobalExtension, TestProcessTileExtension>(x => x
        .WithOrder(ExtensionStage.AfterPlatform, 7)
        .WithUnity(unityContainer
            .RegisterType<TestProcessTileExtension>(new ContainerControlledLifetimeManager())));

Класс, содержащий константы, используемые выше, доступен на клиенте и на сервере. Его код приведён ниже.

namespace Tessa.Extensions.Default.Shared.Workflow.TestProcess
{
    public static class TestProcessHelper
    {
        #region Process Name

        /// <summary>
        /// Имя процесса, используемое для его запуска.
        /// </summary>
        public const string ProcessName = "TestProcess";

        #endregion

        #region SubProcess Names

        /// <summary>
        /// Имя основного подпроцесса, который доступен один на карточку.
        /// </summary>
        public const string MainSubProcess = "Main";

        /// <summary>
        /// Имя подпроцесса "Подпроцесс 1", может запускаться несколько раз из основного подпроцесса <see cref="MainSubProcess"/>.
        /// </summary>
        public const string SubProcess1 = "Process1";

        /// <summary>
        /// Имя тестового сигнала. Отправляется на основной подпроцесс <see cref="MainSubProcess"/>.
        /// </summary>
        public const string TestSignal = "TestSignal";

        #endregion
    }
}

7.2. Установка информации по бизнес-процессу в истории заданий

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

Запись в истории заданий опционально содержит информацию о том, к какому бизнес-процессу относилось (или относится) задание, для которого создана запись. Это позволяет выполнять группировку записей в истории заданий по процессу.

Такая информация содержит следующие поля:

  • ProcessID (Guid) – идентификатор бизнес-процесса, по которому определяется, что записи в истории принадлежат одному и тому же процессу, например, одной и той же резолюции.

  • ProcessName (string) – отображаемое пользователю имя бизнес-процесса. Может также содержать дополнительную информацию по экземпляру процесса.

  • ProcessKind (string) – имя типа бизнес-процесса, определяющее принадлежность бизнес-процесса к некоторому виду процессов, такому как "резолюции". Поле может использоваться для группировки или не быть заданным вообще.

По умолчанию все поля из списка равны null. Для того, чтобы при завершении задания или при его сохранении с созданием записи в истории поля установились, их необходимо задать в объекте CardTask следующим образом:

CardTask task = ...;

// для первого задания в процессе создаём новый ID
task.ProcessID = Guid.NewGuid();
task.ProcessName = "Резолюция - Иванов И., январь 2015 г.";
task.ProcessKind = "Резолюция";

При добавлении записи в историю заданий вручную через объект CardTaskHistoryItem поля требуется установить в этом объекте:

CardTask historyItem = card.TaskHistory.Add();
historyItem.RowID = task.RowID;
historyItem.State = CardTaskHistoryState.Insert;
// устанавливаем другие свойства записи

historyItem.ProcessID = Guid.NewGuid();
historyItem.ProcessName = "Резолюция - Иванов И., январь 2015 г.";
historyItem.ProcessKind = "Резолюция";

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

foreach (CardTaskHistoryItem historyItem in card.TaskHistory)
{
    TessaDialog.ShowMessage(string.Format(
        "Информация по бизнес-процессу для записи RowID={0}:"
        + " ProcessID={1}, ProcessName=\"{2}\", ProcessKind=\"{3}\"",
        historyItem.RowID,
        historyItem.ProcessID,
        historyItem.ProcessName,
        historyItem.ProcessKind));
}

8. Маршруты

Подсистема маршрутов спроектирована с учетом возможности внесения нового функционала без модификации типового решения. Разработчикам предоставляется API для написания собственных типов этапов.

8.1. Программный запуск маршрута с фиксированным набором групп

Для запуска процесса в коде расширений необходимо собрать процесс с помощью KrProcessBuilder, а затем запустить полученный процесс с помощью IKrProcessLauncher, который можно получить из Unity-контейнера. Запустить можно маршрут, собранный в вторичный процесс.

Пример запуска процесса в серверных расширениях

 var process = KrProcessBuilder
    .CreateProcess()
    .SetProcess(/*Идентификатор карточки вторичного процесса*/)
    // Карточка необходима для локальных процессов.
    // Локальный или глобальный процесс - настройка в карточке вторичного процесса
    .SetCard(context.Request.Card.ID)
    // Опционально можно указать ProcessInfo.
    // В скриптах будет доступно в ProcessInfo или WorkflowProcess.Info.
    // Позволяет запускать процесс с параметрами.
    .SetProcessInfo(new Dictionary<string, object())
    .Build();

// Опционально указываются особенности запуска процесса.
// В данном случае указывается, что процесс запустить нужно в текущем выполнении запроса.
// С учетом данной особенности процесс может быть запланирован только в BeforeRequest.
// Если не указать, процесс будет запущен с помощью вложенного сохранения
// В общем случае запускать необходимо именно во вложенном сохранении
var specificParameters = new KrProcessServerLauncher.SpecificParameters
{
    UseSameRequest = true;
};

// context - реализация ICardExtensionContext. В контексты расширений ICardStoreExtensionContext и ICardRequestExtensionContext будут устанавливаться клиентские команды.
// Для работы `UseSameRequest` необходимо передать контекст расширения на сохранение ICardStoreExtensionContext.
var launchResult = this.launcher.Launch(process, context, specificParameters);

var validationResult = launchResult.ValidationResult;
var startedProcessID = launchResult.ProcessID;

Пример запуска процесса в клиентских расширениях

 var process = KrProcessBuilder
    .CreateProcess()
    .SetProcess(/*Идентификатор карточки вторичного процесса*/)
    .SetCard(cardID)
    .SetProcessInfo(new Dictionary<string, object())
    .Build();

var launchResult =await this.launcher.LaunchAsync(process, context);

Пример запуска процесса в клиентских расширениях с использованием текущего ICardEditorModel

ICardEditorModel editor = uiContext.CardEditor;
var process = KrProcessBuilder
    .CreateProcess()
    .SetProcess(/*Идентификатор карточки вторичного процесса*/)
    .SetCard(cardID)
    .SetProcessInfo(new Dictionary<string, object())
    .Build();
var launchResult = await this.launcher.LaunchWithCardEditorAsync(process, editor);

8.2. Дескриптор типа этапа

Дескриптор типа этапа представляет из себя структуру, полностью описывающую и идентифицирующую какой-либо тип этапа. Такая структура содержит в себе идентификатор типа этапа, его название и другую полезную информацию.

Список полей дескриптора:

  • ID - идентификатор типа этапа.

  • Caption - название типа этапа.

  • DefaultStageName - стандартное название типа этапа, которое будет подставляться в KrStages.Name при каждом создании новой строки с этапом.

  • SettingsCardTypeID - идентификатор типа карточки настроек.

  • PerformerUsageMode - режим использования исполнителей. Возможны следующие значения:

    • None - исполнители в этапе не используются.

    • Single - используется одиночный исполнитель. В редакторе этапа будет отображен элемент управления "ссылка" для указания роли, а также в объекте этапа Stage свойство Performer.

    • Multiple - используется несколько исполнителей. В редакторе этапа будет отображен элемент управления "список", а также в объекте Stage свойство Performers.

  • CheckPerformer - проверять заполненность исполнителя/списка исполнителей. Если данный режим включен для типа этапа, но при этом ни одного исполнителя не указано, то при закрытии редактора строки этапа будет показано предупреждение, перед стартом этапа произойдет ошибка.

  • UseTimeLimit - использовать поле Срок. В редакторе строки этапа будет отображен элемент управления для ввода срока. В объектной модели этапа введенная информация доступна в свойстве TimeLimit.

  • SupportedModes - список поддерживаемых этапом режимов. Этапы могут поддерживать следующие режимы:

    • KrProcessRunnerMode.Sync - этап поддерживает синхронный режим, т.е. выполнение за один запуск. Рекомендуется указывать всегда.

    • KrProcessRunnerMode.Async - этап поддерживает асинхронный режим. Рекомендуется указывать, если этап отправляет задания.

Создание своего типа этапа следует начинать с создания дескриптора, сохранив его в статическое поле класса в Shared-сборке.

public static class CustomStageTypeDescriptors
{
    public static readonly StageTypeDescriptor ApprovalDescriptor =
        StageTypeDescriptor.Create(b =>
        {
            b.ID = new Guid(0x185610E1, 0x6AB0, 0x64E, 0x94, 0x29, 0x4C, 0x52, 0x98, 0x4, 0xDF, 0xE4);
            b.Caption = "$KrStages_Approval";
            b.SettingsCardTypeID = new Guid(0x4A377758, 0x2366, 0x47E9, 0x98, 0xAC, 0xC5, 0xF5, 0x53, 0x97, 0x42, 0x36);
            b.PerformerUsageMode = PerformerUsageMode.Multiple;
            b.PerformerIsRequired = true;
            b.UseTimeLimit = true;
            b.SupportedModes.Add(KrProcessRunnerMode.Async);
        });
}

8.3. Настройки типа этапа

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

В качестве примера рассмотрим карточку настроек типа этапа согласование.

  • Карточка настроек этапа в редакторе TessaAdmin

    image routes 1
  • Настройки этапа

    image routes 2

Для связи карточки настроек и типа этапа необходимо указать идентификатор типа карточки в дескрипторе типа этапа в свойстве SettingsCardTypeID.

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

image routes 3

Поддерживаются следующие тэги:

  • Runtime - элемент управления видимый только в документе;

  • DesignTime - элемент управления видимый только в шаблоне этапов;

  • RuntimeReadonly - элемент управления неизменяемый в документе;

  • DesignTimeReadonly - элемент управления неизменяемый в шаблоне этапов;

8.4. Обработчик типа этапа

Основная логика процесса реализуется в обработчике этапа. Обработчик этапа - это класс, который наследует StageTypeHandlerBase. В нем предусмотрены следующие методы:

  • HandleStageStart(context) - запуск этапа. В данном методе отправляются задания в асинхронных этапах и выполняется вся логика в синхронных этапах.

  • HandleTaskCompletion(context) - завершение задания, отправленного этапом. В контексте содержится вся информация о завершаемом задании, в т.ч. информация IWorkflowTaskInfo из WorkflowAPI.

  • HandleTaskReinstate(context) - возврат задания на доработку.

  • HandleSignal(context) - обработка полученного сигнала WorkflowAPI. Сигналы позволяют реализовать внешнее воздействие на этап. Например, при каждом получении сигнала определенного типа этап должен отправлять дополнительное задание на роль, указанную в исполнителях.

  • HandleStageInterrupt(context) - один из важнейших методов обработчика этапа. С помощью этого метода подсистема маршрутов сообщает этапу о том, что ему необходимо прерваться, утилизировав все используемые ресурсы (отзыв заданий и др.). Метод возвращает значение типа bool:

    • true - этап полностью прерван и дополнительных вложенных запросов на завершение заданий НЕ требуется.

    • false - для завершения этапа необходим дополнительный вложенный запрос для завершения заданий. + Для примера рассмотрим прерывание этапа с 4мя заданиями, которые будут отзываться по 2 за запрос. Это учебный пример и на практике можно отзывать все задания за один запрос.

      image routes 4

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

Все методы, кроме HandleStageInterrupt возвращают результат типа StageHandlerResult. С помощью результата происходит взаимодействие с подсистемой маршрутов. Возможны следующие результаты выполнения этапа:

  • StageHandlerResult.EmptyResult - метод-обработчик не знает, как реагировать и сообщает об этом подсистеме маршрутов. На практике поведение аналогично StageHandlerResult.EmptyResult, однако позволяет отличить, выполнил ли обработчик необходимые действия или нет.

  • StageHandlerResult.InProgressResult - этап находится в процессе выполнения, состояние этапа будет выставлено в "Активен". Допустимо использовать только в асинхронных этапах.

  • StageHandlerResult.CompleteResult- этап будет переведен в состояние "Завершен" и управление будет передано следующему этапу маршрута.

  • StageHandlerResult.SkipResult- этап будет переведен в состояние "Пропущен" и управление будет передано следующему этапу маршрута.

  • StageHandlerResult.GroupTransition(Guid stageGroupID, bool keepStageStates = false)- выполнить переход на группу этапов. Если указанная группа существует, то будет произведен пересчет этапов и управление передано первому этапу в группе, иначе будет запущен следующий этап.

  • StageHandlerResult.Transition(Guid stageRowID, bool keepStageStates = false)- выполнить переход на этап. Если указанный этап не существует, то перейти на следующий.

  • StageHandlerResult.SkipProcessResult - пропустить процесс с переводом оставшихся этапов в состояние "Пропущен".

  • StageHandlerResult.CancelProcessResult - отменить процесс с переводом всех этапов в состояние "Не запущен".

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

public sealed class RegistrationStageTypeHandler : StageTypeHandlerBase
{
    #region fields

    private readonly IUnityContainer unityContainer;

    private readonly IKrScope krScope;

    private readonly ISession session;

    private readonly ICardMetadata cardMetadata;

    private readonly IKrStageSerializer serializer;

    private readonly IDbScope dbScope;

    private readonly ICardGetStrategy cardGetStrategy;

    #endregion

    #region constuctor

    public RegistrationStageTypeHandler(
        IUnityContainer unityContainer,
        IKrScope krScope,
        ISession session,
        ICardMetadata cardMetadata,
        IKrStageSerializer serializer,
        IDbScope dbScope,
        ICardGetStrategy cardGetStrategy)
    {
        this.unityContainer = unityContainer;
        this.krScope = krScope;
        this.session = session;
        this.cardMetadata = cardMetadata;
        this.serializer = serializer;
        this.dbScope = dbScope;
        this.cardGetStrategy = cardGetStrategy;
    }

    #endregion

    #region public

    public override StageHandlerResult HandleStageStart(
        IStageTypeHandlerContext context)
    {
        // При запуске этапа определяем, в каком режиме сейчас идет выполнение
        switch (context.RunnerMode)
        {
            case KrProcessRunnerMode.Sync:
                // Выполнение в синхронном режиме, отправка заданий запрещена
                // Выполняем регистрацию
                return this.SyncRegistration(context);
            case KrProcessRunnerMode.Async:
                // Выполнение в асинхронном режиме, отправляем задание регистрации
                return this.AsyncRegistration(context);
            default:
                throw new ArgumentOutOfRangeException();
        }
    }

    public override StageHandlerResult HandleTaskCompletion(
        IStageTypeHandlerContext context)
    {
        // Завершение задания регистрации
        // Вся информация о задании доступна в контексте
        var taskInfo = context.TaskInfo;
        var taskType = taskInfo.Task.TypeID;
        var optionID = taskInfo.Task.OptionID ?? Guid.Empty;

        if (taskType == DefaultTaskTypes.KrRegistrationTypeID
            && optionID == DefaultCompletionOptions.RegisterDocument)
        {
            // Вариант завершения "Зарегистрировать"
            // Удаляем задание из списка активных
            context.WorkflowAPI.TryRemoveActiveTask(taskInfo.Task.RowID);
            // Проводим регистрацию документа
            this.SyncRegistration(context, taskInfo);
            // Сообщаем подсистеме маршрутов о том, что работа этапа завершена.
            return StageHandlerResult.CompleteResult;
        }

        throw new InvalidOperationException();
    }

    public override bool HandleStageInterrupt(IStageTypeHandlerContext context) =>  InterruptSingleTask(context);

    #endregion

    #region private

    private StageHandlerResult SyncRegistration(
        IStageTypeHandlerContext context,
        IWorkflowTaskInfo taskInfo = null)
    {
        // Непосредственная регистрация карточки.
        if (context.MainCardID.HasValue
            && context.MainCardTypeID.HasValue)
        {
            var cardType = this.cardMetadata.CardTypes[context.MainCardTypeID.Value];
            var mainCard = this.krScope.GetMainCard(context.MainCardID.Value);
            var numberDirector = this.unityContainer.ResolveNumberDirector(cardType.Name);
            var numberComposer = this.unityContainer.ResolveNumberComposer(cardType.Name);
            var numberContext = numberDirector.CreateContext(
                numberComposer,
                mainCard,
                cardType,
                context.CardExtensionContext is ICardStoreExtensionContext storeContext
                    ? storeContext.Request.Info
                    : null);
            // выделение номера при регистрации
            numberDirector.NotifyOnRegisteringCard(numberContext);
            context.ValidationResult.Add(numberContext.ValidationResult);
        }
        context.WorkflowProcess.State = KrState.Registered;
        return StageHandlerResult.CompleteResult;
    }

    private StageHandlerResult AsyncRegistration(
        IStageTypeHandlerContext context)
    {
        var api = context.WorkflowAPI;

        // Получаем исполнителя, указанного в настройках этапа.
        // Аналогичное действие можно выполнить с помощью context.Stage.Performer
        var performerID = context.Stage.SettingsStorage.TryGet<Guid?>(KrSinglePerformerVirtual.PerformerID);
        var performerName = context.Stage.SettingsStorage.TryGet<string>(KrSinglePerformerVirtual.PerformerName);
        if (!performerID.HasValue
            || string.IsNullOrWhiteSpace(performerName))
        {
            context.ValidationResult.AddError(this, "Performer not specified");
            return StageHandlerResult.EmptyResult;
        }

        // Установка в карточке состояния "На регистрации"
        context.WorkflowProcess.State = KrState.Registration;

        // Отправка задания регистрации
        var taskInfo = api.SendTask(
            DefaultTaskTypes.KrRegistrationTypeID,
            context.Stage.Name,
            performerID.Value,
            performerName);
        // Добавление задания в список активных заданий,
        // которые будут отображатся в таблице над заданиями.
        api.AddActiveTask(taskInfo.Task.RowID);

        // Результат говорит подсистеме маршрутов о том, что этап находится в процессе выполнения
        return StageHandlerResult.InProgressResult;
    }

    private static bool InterruptSingleTask(
        IStageTypeHandlerContext context)
    {
        if (context.ProcessInfo is null
            || context.MainCardID is null)
        {
            return true;
        }
        var cardID = context.MainCardID.Value;

        var db = this.dbScope.Db;
        // Получение списка заданий из таблицы WorkflowTasks
        var currentTasks = db.SetCommand(
                dbScope.BuilderFactory
                    .Select().Top(2)
                    .C("RowID")
                    .From("WorkflowTasks").NoLock()
                    .Where().C("ProcessRowID").Equals().P("pid")
                    .Limit(2)
                    .Build(),
                db.Parameter("pid", context.ProcessInfo.ProcessID))
            .LogCommand()
            .ExecuteScalarList<Guid>();

        // Метод поддерживает только одно задание для отзыва.
        switch (currentTasks.Count)
        {
            case 0:
                // Заданий нет, прерывание этапа завершено.
                return true;
            case 1:
                // Задание есть, его нужно отозвать.
                return RevokeTask(cardID, currentTasks[0], context);
            default:
                throw new InvalidOperationException("More than one task.");
        }
    }

    private static bool RevokeTask(
        Guid cardID,
        Guid taskID,
        IStageTypeHandlerContext context)
    {
        var validationResult = context.ValidationResult;
        var card = this.krScope.GetMainCard(cardID);
        var cardTasks = card.TryGetTasks();
        if (cardTasks is null
            || cardTasks.All(p => p.RowID != taskID))
        {
            var db = this.dbScope.Db;
            var taskContexts = this.cardGetStrategy.TryLoadTaskInstances(
                card.ID,
                card,
                db,
                this.cardMetadata,
                validationResult,
                this.session.User.ID,
                getTaskMode: CardGetTaskMode.All,
                loadCalendarInfo: false,
                taskRowIDList: new[] { taskID });
            foreach (var taskContext in taskContexts)
            {
                this.cardGetStrategy.LoadSections(taskContext);
            }
        }

        var task = cardTasks?.FirstOrDefault(p => p.RowID == taskID);
        if (task is null)
        {
            return true;
        }

        context.WorkflowAPI.TryRemoveActiveTask(task.RowID);
        task.Action = CardTaskAction.Complete;
        task.State = CardRowState.Deleted;
        task.Flags = task.Flags & ~CardTaskFlags.Locked | CardTaskFlags.UnlockedForAuthor | CardTaskFlags.HistoryItemCreated;
        task.OptionID = DefaultCompletionOptions.Cancel;
        return false;
    }

    #endregion
}

Для ассоциации обработчика с определённым типом этапа необходимо провести регистрацию с указанием дескриптора.

[Registrator]
public sealed class Registrator : RegistratorBase
{
    public override void RegisterUnity()
    {
        this.UnityContainer
            .RegisterType<RegistrationStageTypeHandler>(new ContainerControlledLifetimeManager())
            ;
    }

    public override void FinalizeRegistration()
    {
        this.UnityContainer
            .Resolve<IKrProcessContainer>()
            // Связываем дескриптор типа этапа с типом обработчика
            .RegisterHandler<RegistrationStageTypeHandler>(StageTypeDescriptors.RegistrationDescriptor)

            // Указываем тип задания, который будет использоваться в процессе в этапе регистрации.
            .RegisterTaskType(DefaultTaskTypes.KrRegistrationTypeID);

    }
}
Чтобы тип этапа стал доступен в диалоге выбора при добавлении нового этапа в таблицу необходимо добавить запись в перечисление KrProcessStageTypes, причем идентификатор указать такой же, как и в дескрипторе. Помимо этого, у пользователя должны быть права на добавление типа этапа в группу, что настраивается в карточке типового решения на вкладке "Настройки этапов маршрута".
Для незначительного изменения настроек существующего этапа с возможным переиспользованием его обработчика можно добавить фильтр типа этапа по дескриптору с помощью метода IKrProcessContainer.AddFilter(filter), где фильтр StageTypeFilter.Exclude(handlerID). Данный метод должен вызываться при регистрации обработчиков типов этапов в регистраторе. После этого можно создать собственный дескриптор с измененными настройками и связать его со старым обработчиком. Таким образом, можно изменить внешний вид и настройки этапа без модификации существующего кода.

8.5. Форматтер типа этапа

В таблице с этапами есть столбцы "Срок", "Участники" и "Настройки"

image routes 5

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

Для написания собственного форматтера необходимо создать класс, наследующий StageTypeFormatterBase и переопределить два метода:

  • FormatClient(context) - форматирование ячеек на клиенте. Выполняется при открытии карточки и при каждом закрытии редактора строки. В контексте доступны настройки этапа в виде виртуальных секций.

  • FormatServer(context) - форматирование ячеек на сервере. Выполняется при сохранении карточки. В контексте доступны настройки этапа в виде хранилища ключ-значение. Серверное форматирование может быть полезно для отображения этапов в представлениях и в легком клиенте.

Рассмотрим пример форматтера для этапа смены состояния:

public sealed class ChangeStateStageTypeFormatter : StageTypeFormatterBase
{
    private const string KrChangeStateSettingsVirtual = nameof(KrChangeStateSettingsVirtual);
    private static readonly string SettingsStateName = StageTypeSettingsNaming.PlainColumnName(KrChangeStateSettingsVirtual, "StateName");

    public override void FormatClient(IStageTypeFormatterContext context)
    {
        // На клиенте доступны виртуальные секции.
        var state = context.StageRow.Fields.TryGet<string>(SettingsStateName);
        SetState(state, context);
    }

    public override void FormatServer(
        IStageTypeFormatterContext context)
    {
        // На сервере доступен словарь с настройками.
        var state = context.Settings.TryGet<string>(SettingsStateName);
        SetState(state, context);
    }

    private static void SetState(
        string state,
        IStageTypeFormatterContext context)
    {
        context.DisplaySettings = string.IsNullOrEmpty(state)
            ? string.Empty : state[0] == '$'
            ? string.Concat("{$UI_KrChangeState_State}: ", "{", state, "}")
            : string.Concat("{$UI_KrChangeState_State}: ", state);
    }
}

При регистрации необходимо связать форматтер с дескриптором. Форматтер и регистратор должны располагаться в Shared расширениях, т.к. форматирование происходит как на клиенте, так и на сервере.

[Registrator(Tag = RegistratorTag.Default)]
public class StageTypeFormattersRegistrator : RegistratorBase
{
    public override void RegisterUnity()
    {
        this.UnityContainer
                .RegisterType<ChangeStateStageTypeFormatter>(new ContainerControlledLifetimeManager())
            ;
    }

    public override void FinalizeRegistration()
    {
        this.UnityContainer
            .Resolve<IStageTypeFormatterContainer>()
            .RegisterFormatter<ChangeStateStageTypeFormatter>(StageTypeDescriptors.ChangesStateDescriptor);
    }
}

8.6. UI обработчик типа этапа

Для упрощения написания клиентских расширений для редактирования типа этапа предусмотрены специализированные расширения. Для создания такого расширения необходимо создать класс, наследующий StageTypeUIHandlerBase.

Предусмотрены три метода:

  • Initialize - выполняется при открытии окна редактирования этапа. В данном методе можно проинициализировать элементы управления, выполнить подписки на события.

  • Finalize - выполняется при закрытии окна редактирования этапа. В данном методе необходимо выполнить отписки от событий.

  • Validate - выполняется перед закрытием окна редактирования этапа. В контексте доступен ValidationResult, который можно при необходимости заполнить.

В качестве примера рассмотрим UI обработчик этапа управления процессом.

public sealed class ProcessManagementUIHandler : StageTypeUIHandlerBase
{
    private static readonly object stageMode = 0;
    private static readonly object groupMode = 1;
    private static readonly object signalMode = 5;

    private const string StageGroupAlias = "StageGroup";
    private const string StageRowAlias = "StageRow";
    private const string SignalAlias = "Signal";

    private DefaultFormSimpleViewModel form;
    private IControlViewModel stageControl;
    private IControlViewModel groupControl;
    private IControlViewModel signalControl;
    private bool initialized = false;

    public override void Initialize(
        IKrStageTypeUIHandlerContext context)
    {
        // Достаем все необходимые элементы управления
        this.form = context.RowModel.MainForm as DefaultFormSimpleViewModel;
        if (this.form is null
            || !this.form.Controls.TryGet(StageRowAlias, out this.stageControl)
            || !this.form.Controls.TryGet(StageGroupAlias, out this.groupControl)
            || !this.form.Controls.TryGet(SignalAlias, out this.signalControl))
        {
            return;
        }

        if (this.stageControl is null
            || this.groupControl is null
            || this.signalControl is null)
        {
            return;
        }

        this.initialized = true;

        // Обновляем видимость элементов управления
        this.UpdateVisibility(context.Row[KrConstants.KrProcessManagementStageSettingsVirtual.ModeID]);
        // Подписываемся на события изменения режима
        context.Row.FieldChanged += this.ModeChanged;
    }

    public override void Finalize(
        IKrStageTypeUIHandlerContext context)
    {
        if (this.initialized)
        {
            // Отписываемся от события изменения режима
            context.Row.FieldChanged -= this.ModeChanged;
        }

        this.initialized = false;
    }

    public override void Validate(IKrStageTypeUIHandlerContext context)
    {
        // Проверяем, что режим указан
        if (context.Row.TryGet<int?>(KrConstants.KrProcessManagementStageSettingsVirtual.ModeID) is null)
        {
            // Если режим не указан, то показываем ошибку
            context.ValidationResult.AddError(this, "$KrStages_ProcessManagement_ModeNotSpecified");
        }
    }

    private void ModeChanged(
        object s,
        CardFieldChangedEventArgs args)
    {
        if (args.FieldName != KrConstants.KrProcessManagementStageSettingsVirtual.ModeID)
        {
            return;
        }

        this.UpdateVisibility(args.FieldValue);
    }

    private void UpdateVisibility(
        object value)
    {
        this.stageControl.ControlVisibility = Visibility.Collapsed;
        this.groupControl.ControlVisibility = Visibility.Collapsed;
        this.signalControl.ControlVisibility = Visibility.Collapsed;

        if (Equals(value, stageMode))
        {
            this.stageControl.ControlVisibility = Visibility.Visible;
        }
        else if (Equals(value, groupMode))
        {
            this.groupControl.ControlVisibility = Visibility.Visible;
        }
        else if (Equals(value, signalMode))
        {
            this.signalControl.ControlVisibility = Visibility.Visible;
        }
        this.form.Rearrange();
    }
}

Регистрация обработчика представлена ниже:

[Registrator]
public sealed class Registrator : RegistratorBase
{
    public override void RegisterUnity()
    {
        this.UnityContainer
            .RegisterType<ProcessManagementUIHandler>(new PerResolveLifetimeManager())
            ;
    }

    public override void FinalizeRegistration()
    {
        this.UnityContainer
            .Resolve<IKrProcessUIContainer>()
            .RegisterUIHandler<ProcessManagementUIHandler>(StageTypeDescriptors.ProcessManagementDescriptor)
            ;
    }
}

8.7. События

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

8.7.1. Подписка на события

Для подписки на события необходимо создать класс, наследующий KrEventExtension. В базовом классе для переопределения доступен только один метод:

  • HandleEvent(context) - метод, вызываемый при возникновении события. В контексте доступна информация о полном состоянии процесса в момент возникновения события.

События различаются по типам - это текстовое значение, с помощью которого можно присвоить отдельным событиям уникальный идентификатор, а далее подписываться по этому идентификатору.

В качестве примера рассмотрим подписку на событие регистрации.

public class RegistrationEventExtension: KrEventExtension
{
    public override void HandleEvent(
        IKrEventExtensionContext context)
    {
        // ....
    }
}

Регистрация подписчика выглядит следующим образом:

[Registrator]
public sealed class Registrator: RegistratorBase
{
    public override void RegisterUnity()
    {
        this.UnityContainer
            .RegisterType<RegistrationEventExtension>(new ContainerControlledLifetimeManager());
    }

    public override void RegisterExtensions(IExtensionContainer extensionContainer)
    {
        extensionContainer
            .RegisterExtension<IKrEventExtension, RegistrationEventExtension>(x => x
                .WithOrder(ExtensionStage.AfterPlatform)
                .WithUnity(this.UnityContainer)
                .WhenEventType(DefaultEventTypes.RegistrationEvent))
            ;
    }

}

8.7.2. Генерация событий

Сгенерировать событие очень просто. Достаточно получить через Unity объект, реализующий интерфейс IKrEventManager. Это можно сделать через конструктор или напрямую. Далее достаточно вызвать метод Raise, в который передать тип события, текущий контекст и опционально набор параметров.

this.eventManager.Raise(DefaultEventTypes.RegistrationEvent, context);

8.8. Клиентские команды

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

Команда состоит из типа команды (строка) и набора параметров, представлемых в виде хранилища ключ-значение.

8.8.1. Добавление команды

Добавление команды производится с помощью метода TryAddClientCommand зависимости IKrScope. Все собранные за несколько запросов команды будут объединены в один массив и отправлены на клиент.

В типовом решении предусмотрены следующие стандартные клиентские команды:

  • OpenCard - открыть существующую карточку.

    Пример использования
    this.krScope.TryAddClientCommand(new KrProcessClientCommand(
        DefaultCommandTypes.OpenCard,
        new Dictionary<string, object>
        {
            ["cardID"] = cardID,
        }));
  • CreateCardViaTemplate - создать новую карточку по шаблону на клиенте (карточка не будет сохранена, у пользователя должны быть права на сохранение карточки).

    Пример использования
    this.krScope.TryAddClientCommand(new KrProcessClientCommand(
        DefaultCommandTypes.CreateCardViaTemplate,
        new Dictionary<string, object>
        {
            ["templateID"] = templateID,
        }));
  • RefreshAndNotify - обновить список активных заданий пользователя и показать уведомление при необходимости.

    Пример использования
    this.krScope.TryAddClientCommand(new KrProcessClientCommand(DefaultCommandTypes.RefreshAndNotify));

8.8.2. Создание собственных клиентских команд

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

В команде указываются следующие свойства:

  • CommandType - идентификатор типа команды. По данному значению осуществляется привязка обработчика команды.

  • Parameters - хранилище ключ-значение, заполняемое параметрами команды. Каждая команда определяет свой набор параметров.

В качестве примера рассмотрим команду обновления списка текущих задач и отображение уведомления

public sealed class RefreshAndNotifyClientCommandHandler : ClientCommandHandlerBase
{
    private readonly KrNotificationManager notificationManager;

    public RefreshAndNotifyClientCommandHandler(
        KrNotificationManager notificationManager)
    {
        // В клиентских командах можно получать любые IoC-зависимости
        this.notificationManager = notificationManager;
    }

    public override void Handle(
        KrProcessClientCommand command)
    {
        // Проверяем задания
        if (this.notificationManager.CanCheckTasks())
        {
            this.notificationManager.CheckTasks();
        }
    }
}

Регистрация клиентской команды выглядит следующим образом:

[Registrator]
public sealed class Registrator : RegistratorBase
{
    public override void RegisterUnity()
    {
        this.UnityContainer
            .RegisterType<IClientCommandHandler, RefreshAndNotifyClientCommandHandler>(new ContainerControlledLifetimeManager())
            ;
    }

    public override void FinalizeRegistration()
    {
        this.UnityContainer
            .Resolve<IClientCommandInterpreter>()
            .RegisterHandler<RefreshAndNotifyClientCommandHandler>(DefaultCommandTypes.RefreshAndNotify)
            ;
    }
}

8.9. Глобальные сигналы

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

Только основной процесс (KrProcess) поддерживает прием сигналов. Вторичные процессы сигналы получать не могут.

8.9.1. Стандартные глобальные сигналы

В типовом решении предусмотрены следующие сигналы для управления процессом:

  • KrCancelProcessGlobalSignal - отмена процесса с переводом всех этапов в состояние "Не запущен".

    card
        .GetWorkflowQueue()
        .AddSignal(KrConstants.KrProcessName, KrConstants.KrCancelProcessGlobalSignal);
  • KrCancelProcessGlobalSignal - пропуск процесса с переводом всех незапущенных этапов в состояние "Пропущен".

    card
        .GetWorkflowQueue()
        .AddSignal(KrConstants.KrProcessName, KrConstants.KrSkipProcessGlobalSignal);
  • KrTransitionGlobalSignal - переход внутри процесса. Этап поддерживает несколько различных переходов.

    • Переход на этап

      card
          .GetWorkflowQueue()
          .AddSignal(
              KrConstants.KrProcessName,
              KrConstants.KrTransitionGlobalSignal,
              parameters: new Dictionary<string, object>
              {
                  [KrConstants.StageRowID] = stageRowID,
              });
    • Переход в начало группы

      card
          .GetWorkflowQueue()
          .AddSignal(
              KrConstants.KrProcessName,
              KrConstants.KrTransitionGlobalSignal,
              parameters: new Dictionary<string, object>
              {
                  [KrConstants.StageGroupID] = stageGroupID,
              });
    • Переход в начало текущей группы

      card
          .GetWorkflowQueue()
          .AddSignal(
              KrConstants.KrProcessName,
              KrConstants.KrTransitionGlobalSignal,
              parameters: new Dictionary<string, object>
              {
                  [KrConstants.KrTransitionCurrentGroup] = BooleanBoxes.True,
              });
    • Переход на следующую группу (если следующая группа отсутствует, процесс будет пропущен)

      card
          .GetWorkflowQueue()
          .AddSignal(
              KrConstants.KrProcessName,
              KrConstants.KrTransitionGlobalSignal,
              parameters: new Dictionary<string, object>
              {
                  [KrConstants.KrTransitionNextGroup] = BooleanBoxes.True,
              });
    • Переход на предыдущую группу

      card
          .GetWorkflowQueue()
          .AddSignal(
              KrConstants.KrProcessName,
              KrConstants.KrTransitionGlobalSignal,
              parameters: new Dictionary<string, object>
              {
                  [KrConstants.KrTransitionPrevGroup] = BooleanBoxes.True,
              });

8.9.2. Создание собственных сигналов

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

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

Для примера рассмотрим сигнал отмены процесса. Пример демонстрирует реализацию сигнала, а также прерывание процесса с помощью специального механизма IKrStageInterrupter. "Прерыватель" - это "обратная" сторона прерывания этапа, которая вызывает метод HandleInterrupt обработчика этапа. Сложность отзыва этапа и причина нескольких вызовов HandleInterrupt заключается в том, что часто прерывание этапа сопровождается отзывом заданий, которое выполняется при следующем сохранении карточки. Необходимо завершить все задания, прежде чем продолжить выполнение маршрута.

public sealed class CancelProcessSignalHandler : GlobalSignalHandlerBase
{
    private readonly IKrStageInterrupter interrupter;

    public CancelProcessSignalHandler(
        IKrStageInterrupter interrupter)
    {
        this.interrupter = interrupter;
    }

    /// <inheritdoc />
    public override void Handle(
        IGlobalSignalHandlerContext context)
    {
        // Текущий этап присутствует в контексте
        var currentStage = context.Stage;

        // Выполняем прерывание этапа
        var completelyInterrupted = this.interrupter.InterruptStage(new KrStageInterrupterContext(
            context.Stage,
            context.RunnerContext,
            context.WorkflowAPI,
            context.RunnerMode,
            ci => ci
                ? KrProcessState.Default
                : new KrProcessState(KrConstants.CancelellationProcessState)));

        // Этап полностью прерван, выполнений вложенных сохранений не требуется.
        if (completelyInterrupted)
        {
            context.RunnerContext.CurrentApprovalStageRowID = null;
            KrProcessHelper.SetInactiveStateToAllStages(context.RunnerContext.WorkflowProcess.Stages);
        }
    }
}

Регистрация обработчика глобального сигнала выглядит следующим образом:

[Registrator]
public sealed class Registrator : RegistratorBase
{
    public override void RegisterUnity()
    {
        this.UnityContainer
            .RegisterType<CancelProcessSignalHandler>(new ContainerControlledLifetimeManager())
            ;
    }

    public override void FinalizeRegistration()
    {
        this.UnityContainer
            .Resolve<IKrProcessContainer>()
            .RegisterGlobalSignal<CancelProcessSignalHandler>(KrConstants.KrCancelProcessGlobalSignal)
            ;

    }
}

8.9.3. Фильтрация обработчиков сигналов

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

Фильтр по типу сигнала
[Registrator]
public sealed class Registrator : RegistratorBase
{
    public override void FinalizeRegistration()
    {
        this.UnityContainer
            .Resolve<IKrProcessContainer>()
            .AddFilter(SignalFilter.Exclude(KrConstants.KrCancelProcessGlobalSignal))
            ;
    }
}
Фильтр по паре (тип сигнала; обработчик сигнала)
[Registrator]
public sealed class Registrator : RegistratorBase
{
    public override void FinalizeRegistration()
    {
        this.UnityContainer
            .Resolve<IKrProcessContainer>()
            // Добавляем фильтр на типовой обработчик сигнала
            .AddFilter(
                SignalHandlerFilter.Exclude(
                    new SignalFilterItem(
                        KrConstants.KrCancelProcessGlobalSignal,
                        typeof(CancelProcessSignalHandler))))

            // Добавляем свой тип сигнала
            .RegisterGlobalSignal<CustomCancelProcessSignalHandler>(KrConstants.KrCancelProcessGlobalSignal)
            ;
    }
}

9. Методики разработки в Tessa

9.1. Важные особенности работы с данными карточки через API

Допустим, есть карточка.

Card card = ... ;

У неё есть строковая секция MyEntry и табличная MyTable. Для табличной секции возьмём первую строку.

CardSection myEntry = card.Sections["MyEntry"];

CardSection myTable = card.Sections["MyTable"];
CardRow myRow = myTable.Rows[0];

Есть несколько способов изменить поле MyField для этих секций.

  1. Изменяем поле с оповещением об изменении через Fields. Если устанавливаемое значение отличается от того, что уже установлено в этом поле, то поле отмечается как изменённое и для него генерируются события об изменении (основным из них является FieldChanged, о других можно узнать из интерфейса Tessa.Cards.ICardFieldContainer).

    myEntry.Fields["MyField"] = 42;
    
    myRow.Fields["MyField"] = 42;
    • Такой способ особенно полезен на клиенте для уже отображённой на экране карточки (в расширениях CardUIExtension), чтобы контролы смогли узнать об изменениях и обновить выводимые ими значения.

    • В клиентских расширениях BeforeRequest на сохранение карточки Store использование такого подхода определяет, что изменённые поля попадут на сервер и действительно будут сохранены. Если карточка сохраняется первый раз, то они будут сохранены в любом случае.

    • Этот способ нельзя использовать в расширениях AfterRequest (клиентских и серверных) на загрузку карточки Get и на создание New, т.к. информация об изменениях в полях будет воспринята на клиенте так, будто бы изменения внёс пользователь. Поэтому вкладку с карточкой нельзя будет закрыть без сообщения "карточка изменена", а при сохранении карточка уйдёт на сервер как изменённая.

  2. Изменяем поле через dynamic. Это более удобный способ записи для п.1, в остальном он полностью аналогичен. Использовать его можно только на объекте Card.

    card.DynamicEntries.MyEntry.MyField = 42;
    
    card.DynamicTables.MyTable[0].MyField = 42;
  3. Изменяем поле без оповещений об изменении. При этом поле не отмечается как изменённое и не выполняются события (FieldChanged и др.), посредством которых контролы могли бы обновить своё представление.

    myEntry.RawFields["MyField"] = 42;
    
    myRow["MyField"] = 42;
    • Этот способ полезен, если требуется изменить карточку на сервере в процессе сохранения карточки (где оповещение об изменении полей не имеет смысла).

    • Используйте этот способ, чтобы "обмануть" пользователя и подставить ему фиктивную информацию о полях при загрузке карточки Get или при создании карточки New (на клиенте или на сервере), в т.ч. и для заполнения виртуальных секций.

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

    Dictionary<string, object> myEntryFields = myEntry.GetStorage().Get<Dictionary<string, object>>("Fields");
    myEntryFields["MyField"] = 42;
    
    Dictionary<string, object> myRowFields = myRow.GetStorage();
    myRowFields["MyField"] = 42;

Для получения значений полей может использоваться любой из приведённых выше способов, и все они равнозначны. Способы "без оповещения" (3 и 4) чуть более производительны, но разницы с точки зрения выполняемого кода нет. Подключите using Tessa.Platform, чтобы код ниже выполнялся.

  1. Fields

    int magicNumber = myEntry.Fields.Get<int>("MyField");
    
    int magicNumber = myRow.Fields.Get<int>("MyField");
  2. Dynamic

    int magicNumber = card.DynamicEntries.MyEntry.MyField;
    
    int magicNumber = card.DynamicTables.MyTable[0].MyField;
  3. RawFields

    int magicNumber = myEntry.RawFields.Get<int>("MyField");
    
    int magicNumber = myRow.Get<int>("MyField");
  4. Напрямую в пакете

    Dictionary<string, object> myEntryFields = myEntry.GetStorage().Get<Dictionary<string, object>>("Fields");
    int magicNumber = myEntryFields.Get<int>("MyField");
    
    Dictionary<string, object> myRowFields = myRow.GetStorage();
    int magicNumber = myRowFields.Get<int>("MyField");

9.2. Создание перечисления в схеме данных и связанного с ним поля в карточке

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

  1. Открываем TessaAdmin на вкладке "Схема данных".

    1. Добавляем таблицу типа Enumeration.

    2. В неё добавляем несколько колонок, например ID типа int и Name типа string

    3. Сохраняем схему.

    4. Добавляем в таблицу PK. Для этого кликнем правой по Constraints, жмём Add primary key.

    5. Выбираем созданный PK и видим справа пустой список колонок. Вот в этот список надо добавить колонку ID. Дважды или один раз кликаем по ячейкам таблицы, пока не добьёмся нужного результата. Удаляются колонки нажатием Del.

    6. В случае успеха тут же сохраняем схему. Перезапускаем SchemeEditor и смотрим, что колонки в PK указаны правильно.

    7. Добавляем в таблицу комплексную колонку типа Reference Typified (Null) или (Not Null) на созданную таблицу-перечисление.

    8. Кликаем правой кнопкой по этой колонке, выбираем "Добавить ссылочную колонку". Должна быть создана колонка вида ColumnName рядом с ColumnID.

    9. Сохраняем схему (и применяем её на базу данных) и перезапускаем application pool.

  2. Открываем TessaAdmin на вкладке "Представления"

    1. Создаём новое представление для выбора из неё значений перечисления.

      Пример
      #view(DefaultSortColumn: ElementName, DefaultSortDirection: asc, Paging: optional)
      #param(Alias: Name, Caption: Имя, Hidden: false, Type: nvarchar, Multiple: true)
      #column(Alias: ElementID, Hidden: true)
      #column(Alias: ElementName, Caption: Сегмент страхования, SortBy: cs.Name)
      #reference(ColPrefix: Element, RefSection: CASCO_InsuranceSegments, IsCard: false)
      
      select
        [ID] as ElementID,
        [Name] as ElementName
      from
        CASCO_InsuranceSegments cs with(nolock)
      #if_def_any{
      where 1 = 1
         #param_expr(Name, cs.Name)
      }
      order by #order_by
    2. Не забываем добавить поисковый параметр (см. Name), чтобы работал автокомплит по имени

    3. Не забываем добавить колонки и информацию по ссылке в метаинформацию

  3. Переходим на вкладку "Карточки"

  4. Добавляем в нужную карточку автокомплит (тип "ссылочное поле")

  5. Указываем комплексную колонку со ссылкой на enum, добавленную в п.1.g

  6. Указываем алиас представления, созданного в п.2.

  7. Указываем алиас параметра, созданного в п.2.b

9.3. Добавление строк в коллекционные секции, с которыми связаны контролы

Пусть требуется из кода расширения CardUIExtension или TileExtension добавить строку в коллекционную секцию, причём на форме карточки уже существуют контролы, которые отображают строки этой секции (например, ссылочный список или таблица). Такую строку следует добавлять командами в строго определённом порядке, чтобы контролы правильно отреагировали.

  1. Добавляем строку методом Add() без параметров.

  2. Устанавливаем RowID и прочие поля.

  3. В самом конце устанавливаем State = CardRowState.Inserted.

CardRow deputy = card.Sections["RoleDeputies"].Rows.Add();

deputy.RowID = Guid.NewGuid(); // уникальный идентификатор строки в коллекционной секции
deputy["DeputyID"] = new Guid("...");
deputy["DeputyName"] = "Петров П.П.";

// именно в момент установки State генерируется событие, которое перехватывают контролы и визуально отображают строку
// к этому моменту все остальные поля уже заполнены
deputy.State = CardRowState.Inserted;

9.4. Получение текущей выбранной вкладки в карточке

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

using Tessa.UI.Cards;
using Tessa.UI.Cards.Forms;
using Tessa.UI.Cards.Tabs

ICardModel cardModel = ... ; // модель основной карточки, можно получить из контекста расширения
var mainForm = (DefaultFormMainViewModel)cardModel.MainForm;
var activeTab = (CardTabView)mainForm.Tabs.CurrentItem;
var activeForm = (IFormViewModel)activeTab.Content;
string activeName = activeForm.Name;

if (activeName == null)
{
    // выбрана основная вкладка, у которой имя не задаётся (подсвечено серым в TessaAdmin)
}
else if (activeName = "MyTabName")
{
    // выбрана вкладка с заданным именем
}

9.5. Получение данных выделенной строки табличного контрола и связанных с ними данных карточки.

using System.Linq;
using Tessa.Cards;
using Tessa.UI;
using Tessa.UI.Cards;
using Tessa.UI.Cards.Controls;

ICardModel cardModel = ... ; // модель основной карточки, можно получить из контекста расширения

// получение табличного контрола по уникальному алиасу
IControlViewModel control = cardModel.Controls["ControlName"];
// приведение табличного контрола к GridViewModel
GridViewModel table = control as GridViewModel;
if (table != null)
{
    // получение выделенных в таблице строк
    IList<CardRowViewModel> selectedRows = table.SelectedRows;
    if (selectedRows.Count > 0)
    {
        int index = ... ; // индекс нужной строки среди выделенных

        // получение данных выделенной строки
        T data1 = selectedRows[index].Model.Get<T>("FieldName");

        // получение связанных данных коллекционной секции карточки
        IEnumerable<CardRow> data2 = cardModel.Card.Sections["SectionName"].Rows.Where(x => ...);
    }
}

9.6. Создаём карточку, в которую уже скопированы некоторые из данных текущей открытой карточки

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

Для расширения TileExtension понадобится получить в конструкторе объект IUIHost из Unity и сохранить его значение в поле класса:

using Tessa.Cards;
using Tessa.UI;
using Tessa.UI.Cards;

public CopyCardTileExtension(IUIHost uiHost)
{
    this.uiHost = uiHost;
}

private readonly IUIHost uiHost;

Далее в методе Execute команды нужно сделать следующее:

private void TileCommandAction(object parameter)
{
    ITile tile = (ITile)parameter;    // плитка, для которой была вызвана команда, передаётся в параметр
    ICardModel oldModel = tile.Context.CardEditor.CardModel;

    this.uiHost.CreateCard(
        oldModel.CardType.ID,
        oldModel.CardType.Name,
        tile.Context,
        cardModifierAction: ctx =>
        {
            Card oldCard = oldModel.Card;
            Card newCard = ctx.Card;

            // здесь выполняем копирование нужных полей из oldCard в newCard
            // причём для строк в коллекционных секциях в newCard надо установить состояние
            // для каждой строки cardRow.State = CardRowState.Inserted и cardRow.RowID = Guid.NewGuid();

            // например, копируем коллекционную секцию таким образом:
            CardSection oldSection = oldCard.Sections["SectionName"];
            CardSection newSection = newCard.Sections["SectionName"];
            newSection.Set(oldSection);

            // если секция коллекционная:
            foreach (CardRow row in newSection.Rows)
            {
                row.State = CardRowState.Inserted;
                row.RowID = Guid.NewGuid();
            }
        });
}

9.7. Кастомная валидация полей с использованием красной рамки

Валидация полей с красной рамочкой требует комплексного подхода с написанием нескольких расширений.

  1. Клиентское расширение CardUIExtension.Initialized, которое через control.ValidationFunc добавляет красную рамку, отображаемую по условию на клиенте. Сам факт наличия красной рамки не запрещает сохранение карточки или закрытие строки таблицы (если контрол в таблице).

  2. Если контрол внутри строки таблицы, то можно делать валидацию при закрытии строки. Свойство ValidationFunc в этом случае устанавливает в событии GridViewModel.RowInitializing (контрол получаем как args.RowModel.Controls["alias"] as GridViewModel), а проверка с выдачей сообщения об ошибке в момент закрытия строки - в событии RowValidating, сообщение об ошибке добавляется в args.ValidationResult.

  3. Необходимо серверное расширение CardStoreExtension.BeforeCommitTransaction, которое выполняет фактическую проверку данных на основании того, что есть на момент сохранения, т.е. когда запрос уйдёт на сервер, откроется транзакция на сохранение, сохранятся данные карточки, но транзакция не закроется (и в этот момент никто не будет видеть данные, т.к. удерживается блокировка на сохранение карточки).

    • В серверном расширении в контексте вам приходит карточка, содержащая только изменённые поля и строки: context.Request.Card. Если в секции нет ни одного изменённого поля, то секции также не будет, поэтому перед обращением надо проверить, что она существует через card.Sections.TryGetValue().

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

              if (context.Request.Card.Sections.TryGetValue("DocumentCommonInfo", out CardSection commonInfo)
                && commonInfo.RawFields.ContainsKey("Subject"))
              {
                // поле "тема" было изменено
              }
    • Надо сделать прямой запрос к БД вида db.ExecuteScalar<bool>("select top 1 1 from Table (inner joins) where (conditions)", параметры).

    • В условиях WHERE пишем ситуацию, при которой возникает ошибка, и смотрим на результат метода. Например, условие "ТипКонтрагента = 'юр. лицо' AND (Телефон IS NULL OR Телефон = '')"

    • Если вернуло true, то есть ошибка. Сообщение об ошибке добавляем в валидацию: context.ValidationResult.AddError(this, "Текст ошибки"). При этом сохранение карточки будет отменено, а транзакция откатится.

  4. Есть другой вариант, но более сложный: сделать расширение CardStoreExtension.AfterBeginTransaction. Оно сможет выполнить проверку и отменить сохранение ещё до того, как данные из карточки будут записаны в незавершённую транзакцию. Но для этого надо получить каждое из актуальных значений полей, которые не пришли с карточкой (т.е. не были изменены) из базы данных. А те, которые пришли с карточкой, должны использоваться вместо значений в базе, когда они есть.

  5. Если используется только толстый клиент, то можно проверку вставить в клиентском расширении CardStoreExtension.BeforeRequest.

    • Этот вариант самый простой, потому что в карточке у вас будут абсолютно все поля в том виде, в каком их сохраняет клиент (т.е. часть полей изменены, часть остались как есть).

    • Проверка простая вида string subject = context.Request.Card.DynamicEntries.Subject, успешно отработает, даже когда тема не менялась.

    • Но это небезопасно, в отличие от сервера, т.к. хитрый пользователь может запустить клиентское приложение с предыдущей версией расширений, где этих проверок ещё нет. А также фактические значения полей в карточке по какой-то причине могли измениться без изменения версии карточки (например, прямым запросом к БД), и тогда проверка может работать с неактуальными данными.

9.8. Совмещаем выбор из справочника со вводом вручную

Начиная с Tessa 1.18.2, данный механизм не требуется. Для создания полей, в которых пользователь может как выбрать элемент из справочника, так и ввести вручную, используйте свойства "Разрешить ручной ввод" и "Поле для ручного ввода" элемента управления "Ссылка\Reference". Добавив к этому простое расширение, которое при сохранении карточки автоматически создает элемент справочника, если ввод был осуществлен вручную, и подставляет его идентификатор в карточку, вы получите удобное и простое заполнение справочника без переключений контекстов ввода. Данный пример оставлен в руководстве для общего развития.

Пусть надо разработать карточку SomePartnerCard, в которой пользователь либо даёт ссылку на существующего контрагента из справочника, либо вводит все данные контрагента вручную, после чего контрагент позже заносится в справочник. Сложность в том, что контрагент содержит не только ID и имя, но также и другие поля, такие как полное имя FullName, адрес Address и телефон Phone.

Справочник контрагентов расположен в таблице Partners. Для простоты это физическая таблица, хотя она может быть и виртуальной, если контрагенты расположены во внешней подсистеме, такой как 1С.

9.8.1. Представление Partners

Поля из этой таблицы выбираются через представление Partners, которая предоставляет ссылку Partner на контрагента со всеми полями в ней (не только с ID и Name). Также представление предоставляет параметр Name для фильтрации по имени контрагента.

#view(Paging: none)
#reference(ColPrefix: Partner, RefSection: Partners, DisplayValueColumn: PartnerName, IsCard: true, OpenOnDoubleClick: true)
#column(Alias: PartnerId, Hidden: true)
#column(Alias: PartnerName, Caption: Контрагент, SortBy: p.Name)
#column(Alias: PartnerFullName, Caption: Полное название, SortBy: p.FullName)
#column(Alias: PartnerAddress, Caption: Адрес)
#column(Alias: PartnerPhone, Caption: Телефон)
#param(Alias: Name, Caption: Краткое название, Hidden: false, Type: nvarchar, Multiple: true)
select
    Id as PartnerId,
    Name as PartnerName,
    FullName as PartnerFullName,
    Address as PartnerAddress,
    Phone as PartnerPhone
from Partners p with(nolock)
where 1 = 1
    #param_expr(Name, p.Name)

9.8.2. Таблицы SomePartnerMainInfo и SomePartnerVirtual

Чтобы был возможен как ввод из справочника через представление, так и ввод вручную, в карточке должно быть две таблицы (строковые секции). Таблица SomePartnerMainInfo физически сохраняется в базе данных и отдельно (без комплексной колонки) содержит все вводимые вручную данные, плюс идентификатор контрагента и признак "контрагент введён вручную". Виртуальная таблица SomePartnerVirtual содержит комплексную колонку, ссылающуюся на таблицу Partners и содержащую все ссылочные колонки из тех, что предоставляет представление.

Колонки в таблице SomePartnerMainInfo (Cards, Entries):

  • SomePartner, Reference(Typified) Null, ссылается на таблицу Partners: ссылка на контрагента из справочника.

    • SomePartnerID, Guid Null - идентификатор контрагента из справочника или null, если контрагент введён вручную или ещё не введён.

  • SomePartnerManualMode, Boolean Not Null, Default value = False: признак того, что контрагент вводится вручную (True) или выбран из справочника (False). Если указать значение по умолчанию True вместо False, то при создании карточки будет активироваться режим ручного ввода, а для переключения на выбор из справочника пользователь должен будет нажать флажок.

  • SomePartnerName, String(255) Null: имя контрагента. Название колонки выбирается как префикс SomePartner + название соответствующей колонки в таблице Partners. Тип колонки выбирается в соответствии с типом в таблице Partners, но ему явно указывается Null (чтобы можно было сохранить нашу карточку SomePartnerCard без контрагента). Для всех колонок ниже действуют эти же правила.

  • SomePartnerFullName, String(512) Null: полное имя контрагента.

  • SomePartnerAddress, String(512) Null: адрес контрагента.

  • SomePartnerPhone, String(128) Null: телефон контрагента.

Колонки в таблице SomePartnerVirtual (Cards, Entries, флажок "Is virtual"):

  • SomePartner, Reference(Typified) Null, ссылается на таблицу Partners: ссылка на контрагента из справочника. Содержит все ссылочные колонки, которые есть в представление Partners и аналоги которых есть с таким же именем в таблице SomePartnerMainInfo.

    • SomePartnerID, Guid Null

    • SomePartnerName, String(255) Null

    • SomePartnerFullName, String(512) Null

    • SomePartnerAddress, String(512) Null

    • SomePartnerPhone, String(128) Null

image35

9.8.3. Тип карточки SomePartnerCard

Теперь создадим тип карточки SomePartnerCard, в который включим все созданные колонки в обеих таблицах.

image36

На вкладку карточки добавим колоночный блок с алиасом SomePartner, выводом в 2 колонки и следующими контролами в заданном порядке:

  1. "Контрагент из справочника", ссылочное поле SomePartnerVirtual: SomePartner, алиас SomePartnerID.Auto. Указаны алиас представления Partners и алиас параметра Name. Если требуется заблокировать автодополнение с клавиатуры и выбирать только через троеточие, то последние два поля остаются пустыми. Также при желании можно настроить автокомплит в режиме комбобокса (при этом можно написать представление типа "мои последние контрагенты" для быстрого выбора).

  2. "Имя контрагента", строковое поле SomePartnerMainInfo: SomePartnerName, алиас SomePartnerName.Manual

  3. "Ввести вручную", логическое значение SomePartnerMainInfo: SomePartnerManualMode, без алиаса.

  4. "Полное имя", строковое поле SomePartnerMainInfo: SomePartnerFullName, алиас SomePartnerFullName.ReadOnly.

  5. "Адрес", строковое поле SomePartnerMainInfo: SomePartnerAddress, алиас SomePartnerAddress.ReadOnly.

  6. "Телефон", строковое поле SomePartnerMainInfo: SomePartnerPhone, алиас SomePartnerPhone.Manual

Как видим, добавлены:

  • автокомплит (ссылочное поле) для выбора из справочника, который привязывается к виртуальной таблице SomePartnerVirtual;

  • поле с именем уже из основной таблицы SomePartnerMainInfo прямо под автокомплитом, чтобы одно при нажатии переключателя заменяло автокомплит;

  • переключатель для выбора режима "из справочника / ручной ввод", ссылается на булевское поле из основной таблицы;

  • прочие контролы (в данном случае строковые) для вывода значений из комплексной колонки, причём контролы ссылаются на основную таблицу; среди них контрол для ввода телефона, видимый только в режиме ручного ввода.

image37

Алиасы у контролов указаны неспроста, они подсказывают нам логику их использования, а также упрощают написание расширения, которое управляет их видимостью и поведением. Значения суффиксов в алиасах:

  • .Auto - контрол доступен в режиме выбора из справочника и скрыт в режиме ручного выбора.

  • .Manual - контрол скрыт в режиме выбора из справочника и доступен в режиме ручного выбора.

  • .ReadOnly - контрол отображается только для чтения в режиме выбора из справочника и полностью доступен в режиме ручного выбора.

Видимость и состояние контролов без суффиксов не меняется.

Это позволяет легко скрывать некоторые контролы для различных режимов ввода, а не просто делать их доступными только для чтения. Т.е. в режиме ручного ввода могут быть доступны контролы, которые не показываются в режиме выбора из справочника. Таким контролам в алиасе нужно заменить суффикс .ReadOnly на .Manual. Обратное также верно.

9.8.4. Расширение SomePartnerUIExtension

Для корректного отображения и функционирования типа карточки надо написать расширение типа CardUIExtension. Оно выполнится на клиенте сразу после открытия вкладки с карточкой, подготовит её данные и подпишется на события, которые позволят корректно реагировать на ввод контрагента, а также на изменение способа ввода с ручного на выбор из справочника, и наоборот.

Класс расширения будет выглядеть следующим образом:

using System.Collections.Generic;
using System.Linq;
using System.Windows;
using Tessa.Cards;
using Tessa.Platform.Storage;
using Tessa.UI.Cards;

namespace Tessa.Extensions.Client.UI
{
    public sealed class SomePartnerUIExtension : CardUIExtension
    {
        // методы расширения
    }
}

Метод SetupControls поможет устанавливать свойства контролов в соответствии с режимом ввод. Параметр метода block содержит ссылку на модель блока карточки со всеми контролами ввода, а параметр manualMode равен true для ручного ввода и false для ввода из справочника.

  • Метод устанавливает или сбрасывает режим "только для чтения" для контролов, алиас которых заканчивается на .ReadOnly

  • Метод показывает или скрывает контролы, алиасы которых заканчиваются на .Auto или .Manual.

  • Метод вызывает block.Rearrange(), чтобы перераспределить пространство для скрытых/показанных контролов блока. Вызов Rearrange необходим, т.к. блок многоколоночный и не способен динамически скрывать контролы без перерисовки.

private static void SetupControls(IBlockViewModel block, bool manualMode)
{
    foreach (IControlViewModel control in block.Controls)
    {
        if (control.Name != null)
        {
            if (control.Name.EndsWith(".ReadOnly"))
            {
                control.IsReadOnly = !manualMode;
            }
            else if (control.Name.EndsWith(".Auto"))
            {
                control.ControlVisibility = manualMode ? Visibility.Collapsed : Visibility.Visible;
            }
            else if (control.Name.EndsWith(".Manual"))
            {
                control.ControlVisibility = manualMode ? Visibility.Visible : Visibility.Collapsed;
            }
        }
    }

    block.Rearrange();
}

Метод Initialized - основной для расширения, он вызывается платформой при открытии вкладки с карточкой (новой или существующей) или при обновлении карточки (после сохранения или нажатия плитки "Обновить"). Регистрация расширения укажет, что это именно вкладка с карточкой нужного типа (см. ниже). Алгоритм метода указан в комментариях.

Метод написан таким образом, чтобы его можно было легко адаптировать:

  • для дополнительных полей в ссылке (новое поле Partners.SAPCode), при этом исправлять код не придётся, только корректно дать имена колонкам и алиасы контролам;

  • для изменённых типов полей или новых полей с типами, отличными от строкового, при этом исправлять код не придётся;

  • для нескольких ссылок из одной карточки (несколько контрагентов), в таком случае меняется лишь проверка префикса полей SomePartner;

  • для других типов ссылок (не на контрагента, а, например, на пользователя), может потребоваться только поменять префикс полей SomePartner.

public override void Initialized(ICardUIExtensionContext context)
{
    CardSection mainTable = context.Model.Card.Sections["SomePartnerMainInfo"];
    CardSection virtualTable = context.Model.Card.Sections["SomePartnerVirtual"];

    // копируем поля в виртуальную секцию, чтобы отобразить в автокомплите
    foreach (KeyValuePair<string, object> field in mainTable.Fields)
    {
        if (field.Key.StartsWith("SomePartner") && !field.Key.EndsWith("ManualMode"))
        {
            virtualTable.Fields[field.Key] = field.Value;
        }
    }

    // начальная видимость контролов
    IBlockViewModel block = context.Model.Blocks["SomePartner"];
    SetupControls(block, mainTable.Fields.Get<bool>("SomePartnerManualMode"));

    // изменение видимости контролов при нажатии на checkbox
    mainTable.FieldChanged += (s, e) =>
    {
        if (e.FieldName.EndsWith("ManualMode"))
        {
            // изменение видимости контролов
            SetupControls(block, (bool)e.FieldValue);

            // очистка введённых в предыдущем режиме данных
            foreach (string fieldName in mainTable.Fields.Keys.ToArray())
            {
                if (fieldName.StartsWith("SomePartner") && fieldName != e.FieldName)
                {
                    mainTable.Fields[fieldName] = null;
                    virtualTable.Fields[fieldName] = null;
                }
            }
        }
    };

    // копируем поля в физическую секцию, чтобы отобразить в строковых контролах
    virtualTable.FieldChanged += (s, e) =>
    {
        if (e.FieldName.StartsWith("SomePartner"))
        {
            mainTable.Fields[e.FieldName] = e.FieldValue;
        }
    };
}

Расширение регистрируется на клиенте для нашего типа карточки SomePartnerCard:

extensionContainer
    .RegisterExtension<ICardUIExtension, SomePartnerUIExtension>(x => x
        .WithOrder(ExtensionStage.AfterPlatform)
        .WithSingleton<SomePartnerUIExtension>()
        .WhenCardTypes("SomePartnerCard"));

9.8.5. Карточка в TessaClient

Так выглядит наша карточка при создании:

image38

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

image39

Пользователь передумал и ставит флажок "Ввести вручную". При этом все поля очищаются, автокомплит "превращается" в строковое поле с другим заголовком, появляется поле для ввода телефона, а поля, ранее доступные только для чтения, становятся редактируемыми.

image40

Пользователь вводит всю инфу о контрагенте вручную. Если он опять сбросит галку "Ввести вручную", то опять появится автокомплит и очистятся значения всех полей.

image41

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

9.9. Расширение на обращение к базе при загрузке карточки

9.10. Скрытие плиток (тайлов) и горячие клавиши

Управлять состоянием плиток можно напрямую через 3 их свойства:

  • IsEnabled - определяет доступность плитки и выполнимость её команды в свойстве Command. Если равно false, то плитка становится серой и на ней нельзя кликнуть.

  • IsCollapsed - плитка не отображается на экране и не занимает места.

  • IsHidden - плитка скрыта, но под неё отводится место на экране так, чтобы её можно было показать, не переоткрывая панель.

  • IsVisible (только чтение) - плитка видна на экране, т.е. свойства IsCollapsed и IsHidden установлены в false.

Команду плитки можно выполнить, если IsEnabled = true и метод CanExecute команды вернул true. Сама плитка при этом может быть скрыта на экране, поэтому пользователь может не иметь возможности кликнуть по плитке и вызвать её команду. Но команду можно выполнить из любого места в коде:

using Tessa.UI.Tiles;

bool executed = tile.ExecuteCommandWithCheck();

9.11. Горячие клавиши

Команду плитки также можно вызвать по хоткею. Хоткей добавляется в коллекцию IUIContext.InputBindings и работает, пока активен контекст, т.е. выделена вкладка, связанная с контекстом. Добавить хоткей можно также в любой момент, когда уже есть контекст, но лучше это делать в расширении на открытие вкладки InitializingLocal:

ITile saveCard = context.Workspace.LeftPanel.TryGet("SaveCard");
if (saveCard != null)
{
    saveCard.Context.AddKeyBinding(saveCard, Key.S, ModifierKeys.Control);
}

Или так:

public static readonly KeyGesture SaveCardKey = new KeyGesture(Key.S, ModifierKeys.Control, "Ctrl+S");

...

ITile saveCard = context.Workspace.LeftPanel.TryGet("SaveCard");
if (saveCard != null)
{
    saveCard.Context.AddInputBinding(saveCard, SaveCardKey);
}

Плитка создаётся в расширениях InitializingGlobal или InitializingLocal, причём для неё можно добавить ToolTip с указанием горячей клавиши.

ITile tile = new Tile("SaveCard" , "Сохранить", ...,
    toolTip: TileHelper.GetToolTip("Сохраняет карточку", SaveCardKey));

Нужно сделать так, чтобы в состоянии плитки IsEnabled = false хоткей перестал работать. Поэтому управлять состоянием IsEnabled напрямую в расширении открытия панели OpeningLocal не рекомендуется. Вместо этого используется специальное событие Evaluating.

9.11.1. Событие Evaluating

Смысл Evaluating – вычислить текущие значения IsEnabled, IsCollapsed и IsHidden, и опционально их применить. Дизейбл и скрытие имеют более высокий приоритет, чем обратные значения, поэтому если хотя бы один обработчик установит SetIsEnabled(false) или SetIsEnabledWithCollapsing(false), то плитка будет дизейблена. Если состояние IsEnabled плитки определяется исключительно через Evaluating и не задаётся явно через свойство, то в любой момент (без расширений на открытие панели) можно сказать, будет ли плитка дизейблена, и, соответственно, будет ли отключён хоткей.

Событие рекомендуется добавлять в расширениях InitializingGlobal или InitializingLocal.

tile1.Evaluating += (s, e) => e.SetIsEnabledWithCollapsing(this.session.User.IsAdministrator); // скрываем и дизейблим плитку для не-админов
tile2.Evaluating += (s, e) => e.SetIsEnabled(this.session.User.IsAdministrator);               // дизейблим плитку для не-админов, но не скрываем её в панели
tile3.Evaluating += TileHelper.DisableTileWithCollapsingHandler;                               // навсегда скрываем и дизейблим плитку (для текущей вкладки)
tile3.Evaluating += TileHelper.DisableTileHandler;                                             // навсегда дизейблим плитку без скрытия (для текущей вкладки)

Также это можно делать в конструкторе плитки, а позднее, в другом расширении, можно добавить ещё несколько обработчиков.

ITile saveCard = new Tile("SaveCard", "Сохранить", ...,
    evaluating: (s, e) => ...);

В самом начале цепочки расширений при открытии панели OpeningLocal будут вычислены и заданы значения указанных свойств для всех отображаемых плиток в панели следующим образом:

TileEvaluationResult result = context.Panel.Evaluate();
result.Apply();

Можно вычислить актуальные значения для конкретной плитки tile, не изменяя сами свойства:

ITile tile = ... ;
TileEvaluationResult result = tile.EvaluateSelf();
bool shouldBeEnabled = result.EventArgs.GetIsEnabledEffective(tile);

9.11.2. Пример расширения на плитку "создание резолюции"

using System.Collections.Generic;
using System.Windows.Input;
using Tessa.Cards;
using Tessa.UI;
using Tessa.UI.Cards;
using Tessa.UI.Tiles;
using Tessa.UI.Tiles.Extensions;

namespace Tessa.Extensions.Client.Tiles
{
    public sealed class SendTaskTileExtension : TileExtension
    {
        private static void EnableOnUpdateAndAllowTasks(object sender, TileEvaluationEventArgs e)
        {
            // плитка будет доступна, если карточка хотя бы раз была сохранена (Update)
            // и для типа карточки разрешены задания (AllowTasks)

            ICardEditorModel editor = e.CurrentTile.Context.CardEditor;
            ICardModel model;

            e.SetIsEnabledWithCollapsing(
                e.CurrentTile,
                editor != null
                && (model = editor.CardModel) != null
                && model.CardType.Flags.Has(CardTypeFlags.AllowTasks)
                && model.Card.StoreMode == CardStoreMode.Update);
        }

        private static async void SendTaskAction(object parameter)
        {
            // действие по созданию резолюции, выполняется асинхронно

            IUIContext context = UIContext.Current;

            ICardEditorModel editor;
            ICardModel model;
            if ((editor = context.CardEditor) != null
                && !editor.OperationInProgress
                && (model = editor.CardModel) != null)
            {
                if (model.HasChanges())
                {
                    await editor.SaveCardAsync(context);
                }

                await editor.OpenCardAsync(
                    model.Card.ID,
                    model.CardType.ID,
                    model.CardType.Name,
                    context,
                    new Dictionary<string, object> {{ "need_starting", true }});
            }
        }

        // горячая клавиша на создание резолюции
        private static readonly KeyGesture sendTaskKey =
            new KeyGesture(Key.R, ModifierKeys.Control | ModifierKeys.Shift, "Ctrl+Shift+R");

        public override void InitializingGlobal(ITileGlobalExtensionContext context)
        {
            // плитка добавляется с обработчиком Evaluating, указанном в конструкторе

            // в расширениях InitializingLocal для специальных видов вкладок, таких как просмотр удалённой карточки,
            // плитку можно будет принудительно скрыть, добавив обработчик tile.Evaluating += TileHelper.DisableTileWithCollapsingHandler

            context.Workspace.LeftPanel.Tiles.Add(
                new Tile(
                    "SendTask",
                    "Создать резолюцию",
                    context.Icons.Get("Thin91"),
                    context.Workspace.LeftPanel,
                    new DelegateCommand(SendTaskAction),
                    TileGroups.Cards,
                    order: 100,
                    toolTip: TileHelper.GetToolTip("Создаёт задание с параметрами резолюции", sendTaskKey.DisplayString),
                    evaluating: EnableOnUpdateAndAllowTasks));
        }

        public override void InitializingLocal(ITileLocalExtensionContext context)
        {
            // добавляем горячую клавишу, которая будет работать только тогда, когда плитка не дизейблена

            ITile sendTask = context.Workspace.LeftPanel.Tiles["SendTask"];
            sendTask.Context.AddInputBinding(sendTask, sendTaskKey);
        }
    }
}

Регистрация расширения:

extensionContainer
    .RegisterExtension<ITileGlobalExtension, SendTaskTileExtension>(x => x
        .WithOrder(ExtensionStage.AfterPlatform)
        .WithSingleton<SendTaskTileExtension>());

9.11.3. Скрытие системных плиток

Все плитки, которые автоматически создаются платформой, могут быть скрыты пользовательскими расширениями по определённым условиям (или скрыты всегда), причём они могут быть найдены по именам в классе Tessa.UI.Tiles.TileNames. Для них точно так же надо написать расширение, запрещающее их отображать, добавив соответствующую подписку на событие Evaluating на уровне расширения CardUIExtension/InitializingLocal.

Приведём пример расширения, скрывающего плитку "Договор", создающую одноимённый тип карточки. Однако, отметим, что если речь про конкретно типы карточек, то в системе для них имеются готовые настройки (в TessaAdmin при редактировании типа). Флажок Hidden скрывает тип карточки ото всех пользователей, при этом он по-прежнему может быть создан, но только системными средствами. Флажок Administrative запрещает создание и работу с типом карточки любым пользователям, кроме администраторов, при этом плитка создания типа тоже скрывается. Если нужно учесть некоторое более сложное условие (например, какая именно вкладка открыта в настоящий момент), то подходит решение, предложенное в примере ниже.

public sealed class HideContractTileExtension : TileExtension
{
    public override void InitializingLocal(ITileLocalExtensionContext context)
    {
        ITilePanel panel = context.Workspace.RightPanel;

        // при необходимости здесь можно проверить текущий контекст IUIContext context = panel.Context;

        // получаем плитку "Создать карточку"
        ITile createCard = panel.Tiles.TryGet(TileNames.CreateCard);
        if (createCard != null)
        {
           // на этом уровне плитки можно получить по имени группы Group, указанной в TessaAdmin для типа карточки

           // получаем плитку "Документы"
           ITile documents = createCard.Tiles.TryGet(TileNames.DocumentTypes);
           if (documents != null)
           {
               // на этом уровне плитки можно получить по имени типа карточки (поле Name для типа карточки в TessaAdmin)

               // получаем плитку "Договор"
               ITile contract = documents.Tiles.TryGet("Contract");
               if (contract != null)
               {
                   // скрываем этот тип и выполняем Disable
                   // т.е. даже если с плиткой ассоциирован хоткей, он прекратит работать
                   contract.Evaluating += TileHelper.DisableTileWithCollapsingHandler;
               }
           }
        }
    }
}

9.12. Импорт сотрудников

Пишем простую утилиту, создающую сотрудников в коде через API ролей и прямой connection к базе.

  1. Создаём консольное приложение.

  2. Делаем референс на Tessa.dll, NLog.dll

  3. В файле app.json пишем строку подключения:

    {
        "ConnectionStrings": {
            "default": "Server=.\\SQLEXPRESS; Database=tessa; Integrated Security=false; User ID=sa; Password=Master1234; Connect Timeout=200; pooling='true'; Max Pool Size=200; MultipleActiveResultSets=true;"
        }
    }
  4. В приложении пишем код:

    using Tessa.Platform.Data;
    using Tessa.Platform.Runtime;
    using Tessa.Roles;
    
    public class Program
    {
        public static void Main(string[] args)
        {
            var dbScope = new DbScope(() => ConfigurationManager.CreateDbManager());
            var roleRepository = new RoleRepository(dbScope);
            using (dbScope.Create())
            {
                var userRole = new PersonalRole
                {
                    ID = Guid.NewGuid(),
                    Modified = DateTime.UtcNow,
                    ModifiedByID = Session.SystemID,
                    ModifiedByName = Session.SystemName,
                    Name = "Имя пользователя",
                    FullName = "Полное имя пользователя",
                    Login = "Аккаунт в AD (необязательно)",
                    // другие необязательные поля
                };
    
                // добавляем пользователя в свою персональную роль перед созданием (RowID – это не ID роли)
                var userRecord = new RoleUserRecord { RowID = Guid.NewGuid(), Role = userRole, User = userRole };
                userRecord.UpdateFromAssociations();
    
                userRole.Users = new List<RoleUserRecord> { userRecord };
                roleRepository.Insert(userRole);
    
                // если создаём более одного пользователя, то это надо делать до закрытия using-а, чтобы соединение с базой было одно и то же
                // объекты userRole и userRecord можно использовать повторно для создания других пользователей, но ID и RowID надо обновлять
                userRole.ID = Guid.NewGuid();
                userRole.Name = "Имя другого пользователя";
                userRole.FullName = "Полное имя другого пользователя";
                userRole.Login = "SomeComputer\\SomeUser";
                userRecord.RowID = Guid.NewGuid();
                roleRepository.Insert(userRole);
            }
        }
    }

9.13. Особенности загрузки карточек

В клиентском расширении иногда есть смысл загрузить другую карточку, например, карточку-сателлит.

public sealed class SomeExtension : CardGetExtension
{
    public SomeExtension(ICardRepository cardRepository)
    {
        this.cardRepository = cardRepository;
    }

    private readonly ICardRepository cardRepository;

    public override void AfterRequest(ICardGetExtensionContext context)
    {
        CardGetResponse response = this.cardRepository.Get(new CardGetRequest { CardID = ... });
        Card satelliteCard = response.Card;

        // используем загруженную satelliteCard
    }
}

Регистрируем расширение:

extensionContainer
    .RegisterExtension<ICardGetExtension, SomeExtension>(x => x
        .WithOrder(ExtensionStage.AfterPlatform, 1)
        .WithUnity(unityContainer
            .RegisterType<SomeExtension>(
                new ContainerControlledLifetimeManager())));

При этом в момент выполнения загрузки основной карточки резолвится расширение SomeExtension, которому в конструктор передаётся ICardRepository вместе с расширениями. Именно он и используется в методе AfterRequest.

По умолчанию не выполняется расширение по сжатию пакета карточки. Поэтому в satelliteCard приходит карточка с несжатым пакетом, которую можно использовать без распаковки методом CardHelper.Decompress(satelliteCard). Сжата карточка или нет можно точно узнать, проверив булевское свойство response.Compressed. Для того, чтобы выполнялось сжатие, нужно включить сжатие в запросе, указав CompressionMode. На сервере этого делать нельзя, но на клиенте это рекомендуемая практика:

CardGetResponse response = this.cardRepository.Get(new CardGetRequest { CompressionMode = CardCompressionMode.Full, CardID = ... });
Card satelliteCard = response.Card;

Чтобы не использовать расширения при загрузке карточки, нужно получить ICardRepository без расширений. Для этого надо изменить регистрацию расширения:

extensionContainer
    .RegisterExtension<ICardGetExtension, SomeExtension>(x => x
        .WithOrder(ExtensionStage.AfterPlatform, 1)
        .WithUnity(unityContainer
            .RegisterType<SomeExtension>(
                new ContainerControlledLifetimeManager(),
                new InjectionConstructor(
                    new ResolvedParameter<ICardRepository>(
                        CardRepositoryNames.Default)))));

Следует проявить осторожность при использовании ICardRepository на сервере вместе с расширениями. Некоторые разработчики расширений ожидают, что в клиентских расширениях перед запросом выполняется подготовка к действию, а в клиентских расширениях после запроса выполняется некое изменение загруженной карточки, которое доступно только на клиенте. Если полностью вырезать клиентские расширения из этой цепочки, то можно ожидать непредсказуемое поведение. В платформенных расширениях такое есть только при явном указании сжатия карточки при загрузке на сервере, но в пользовательских расширениях может быть всё что угодно. Чтобы не выполнять пользовательские расширения, а выполнять только платформенные, надо указать при регистрации вашего расширения резолв ICardRepository по имени CardRepositoryNames.Platform. При этом при загрузке карточки на клиенте по-прежнему понадобится задать CompressionMode.

В примере выше при использовании компонента ICardGetComponent вместо ICardRepository также следует указывать имя CardRepositoryNames.Default при регистрации в Unity.

9.14. Система уведомлений

9.14.1. Добавление простых уведомлений по заданиям.

Система предоставляет методы для упрощения отправки уведомлений по отправляемым заданиям. Для того, чтобы отправить уведомление о созданном задании - нужно воспользоваться классом Tessa.Extensions.Shared\Notices\NotificationHelper. Этот класс реализует перегруженный AddNotification, с помощью которого отправляются уведомления.

Уведомления отправляются только в том случае, если сохранение карточки прошло успешно. Для этого метод добавляет информацию о необходимости отправки уведомлений в специальный раздел секции Info Request’а на сохранение карточки. Если сохранение карточки прошло успешно, специальное расширение AfterRequest срабатывает после сохранения, получает информацию из секции Info и формирует и передает сообщения в сервис отправки почты. За это отвечает расширение Tessa.Extensions.Server\Notices\NotificationTaskExtension.

Все это позволяет создавать типовые уведомления одной строкой кода, реализуя самый распространенный сценарий отправки уведомлений.

Метод имеет четыре перегрузки:

    public static void AddNotification(CardInfoStorageObject request, IList<TaskNotification> notifications)
    public static void AddNotification(CardInfoStorageObject request, TaskNotification notification)
    public static void AddNotification(CardInfoStorageObject request, List<CustomNotification> notifications)
    public static void AddNotification(CardInfoStorageObject request, CustomNotification notification)

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

    Д-00007, тема
    Исполнитель: Администратор
    Автор: Администратор
    Выдано: 09.07.2014 14:35
    Срок: 10.07.2014 14:35
    Тип: Задача
    Тестовое содержание задания
    С переносом строк

Если передать параметр Body, то в качестве тела письма будет использовано содержимое этого параметра.

Пример использования в Tessa.Extensions.Server\Workflow\TaskManager:

        public void NewExecuteTaskTask(CardStoreRequest request, CardSection infoSection)
        {
            CardType cardType;
            if (!this.cardMetadata.CardTypes.TryGetValue("execute_task", out cardType))
            {
                return;
            }

            var taskResponse = this.cardRepository.New(new CardNewRequest { CardTypeID = cardType.ID });
            taskResponse.Card.ID = Guid.NewGuid();

            var cardTask = request.Card.Tasks.Add();
            cardTask.SetCard(taskResponse.Card);
            cardTask.SectionRows = taskResponse.SectionRows;
            cardTask.RoleID = infoSection.Fields.Get<Guid>("RoleID");
            cardTask.RoleName = infoSection.Fields.Get<string>("RoleName");
            cardTask.Planned = infoSection.Fields.Get<DateTime>("CompleteTo");
            cardTask.State = CardRowState.Inserted;

            // Записываем данные в TaskCommonInfo
            var commonInfo = cardTask.Card.Sections.GetOrAdd("TaskCommonInfo");
            commonInfo.Fields["Info"] = infoSection.Fields.Get<string>("TaskComment");

            this.MergeStorage(cardTask, infoSection, "Test_Task_short_info");

            // Добавляем уведомление
            NotificationHelper.AddNotification(request, cardTask.Card.ID);
        }

Третья и четвёртая перегрузки позволяют отправить полностью уникальное уведомление, которое не зависит от содержания задания.
Этот метод принимает на вход адрес получателя, тему и тело сообщения внутри обёртки CustomNotification, что позволяет отправить любое уведомление любому пользователю.

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

9.14.2. MailService. Как отправлять письма напрямую.

Для отправки писем независимо от цепочки сохранения, можно использовать API MailService. Для этого вы резолвите из Unity IMailService (см. ниже пример) и дергаете его метод PostMessage.

this.mailService.PostMessage(email, subject, body, validationResult);

Сам по себе IMailService не занимается отправкой сообщений. Он добавляет подготовленные к отправке данные в таблицу Outbox, откуда их в свою очередь забирает специальный плагин системного сервиса Chronos: NoticeMailerPlugin, который и отправляет письмо.

MailService может работать в двух режимах Default и WithoutTransaction. Режим работы определяется при регистрации расширений использующих IMailService.

            extensionContainer.RegisterExtension<ICardStoreExtension, NotificationTaskExtension>
                (x => x
                    .WithOrder(ExtensionStage.AfterPlatform)
                    .WithUnity(unityContainer.RegisterType<NotificationTaskExtension>(
                        new PerResolveLifetimeManager(),
                        new InjectionConstructor(
                            new ResolvedParameter<IMailService>(MailServiceNames.Default),
                            typeof(IDbScope),
                            typeof(ISession)))));

В режиме "WithoutTransaction" MailService вклинивается в уже существующую транзакцию и работает в её рамках. Это может быть удобно для простой отмены отправки писем. При откате основной транзакции откатятся и все письма, записанные в Outbox в ее рамках.
В режиме "Default" MailService создаёт свою собственную транзакцию. Этот режим позволяет работать независимо от всего остального.

9.15. Календарь

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

9.15.1. Квант времени

Для отражения момента времени используется специальное понятие кванта времени. Существует два типа квантов времени - рабочий и не рабочий.

  • Рабочий квант - Длится 15 минут. На такие кванты разбит весь рабочий день.

  • Нерабочий квант - длится всё время с момента конца предыдущего рабочего кванта и до момента начала следующего рабочего кванта.

Таблица с квантами времени имеет следующую структуру:

  • QuantNumber - номер кванта.

  • StartTimeUTC – дата/время начала кванта (включительно).

  • EndTimeUTC – дата/время окончания кванта (не включительно).

  • Type - Тип кванта (0 - рабочее время, 1 - выходной).

Рассчитанные кванты придерживаются нескольких основных правил:

  • Номера квантов идут по порядку и неразрывны.

  • Кванты нерабочего времени имеют те же номера, что и идущий перед ним квант.

  • Рабочее время разбито на кванты по 15 минут, нерабочее время заносится единым квантом.

  • Между квантами не должно быть промежутка по времени. Конец одного кванта равен началу следующего за ним кванта.

Для примера рассмотрим отрывок из рассчитанного календаря:

QuantNumber StartTimeUTC EndTimeUTC Type

0

2014-01-01 00:00:00

2014-01-01 05:00:00

1

1

2014-01-01 05:00:00

2014-01-01 05:15:00

0

2

2014-01-01 05:15:00

2014-01-01 05:30:00

0

3

2014-01-01 05:30:00

2014-01-01 05:45:00

0

…​

…​

…​

…​

14

2014-01-01 08:15:00

2014-01-01 08:30:00

0

15

2014-01-01 08:30:00

2014-01-01 08:45:00

0

16

2014-01-01 08:45:00

2014-01-01 09:00:00

0

16

2014-01-01 09:00:00

2014-01-01 11:00:00

1

17

2014-01-01 11:00:00

2014-01-01 11:15:00

0

18

2014-01-01 11:15:00

2014-01-01 11:30:00

0

19

2014-01-01 11:30:00

2014-01-01 11:45:00

0

…​

…​

…​

…​

22

2014-01-01 12:15:00

2014-01-01 12:30:00

0

…​

…​

…​

…​

30

2014-01-01 14:15:00

2014-01-01 14:30:00

0

31

2014-01-01 14:30:00

2014-01-01 14:45:00

0

32

2014-01-01 14:45:00

2014-01-01 15:00:00

0

32

2014-01-01 15:00:00

2014-01-06 05:00:00

1

33

2014-01-06 05:00:00

2014-01-06 05:15:00

0

34

2014-01-06 05:15:00

2014-01-06 05:30:00

0

35

2014-01-06 05:30:00

2014-01-06 05:45:00

0

…​

…​

…​

…​

48

2014-01-06 08:45:00

2014-01-06 09:00:00

0

48

2014-01-06 09:00:00

2014-01-06 11:00:00

1

49

2014-01-06 11:00:00

2014-01-06 11:15:00

0

…​

…​

…​

…​

64

2014-01-06 14:45:00

2014-01-06 15:00:00

0

64

2014-01-06 15:00:00

2014-01-07 05:00:00

1

65

2014-01-07 05:00:00

2014-01-07 05:15:00

0

Важно помнить, что календарь оперирует временем в UTC. Видно, что квант с номером 0, - квант нерабочего времени, он заканчивается 2014-01-01 05:00. Следом за ним идут кванты рабочего времени по 15 минут, до момента 2014-01-01 09:00, когда начнётся обед (квант нерабочего времени), который длится до 2014-01-01 11:00. Далее до 2014-01-01 15:00 идут снова кванты рабочего времени по 15 минут, пока не появится квант нерабочего времени, отражающий ночь, и длящийся до 2014-01-06 05:00.

9.15.2. Карточка настроек календаря

Для настройки календаря в системе есть специальная карточка настроек.
Она содержит в себе:

  • Таблица исключений.

  • Период действия календаря.

  • Интервалы рабочего дня и обеденного перерыва.

  • Кнопки, вызывающие пересчёт календаря и проверку целостности рассчитанного календаря.

image43

Подробнее о настройке календаря см. Руководство администратора.

9.15.3. Работа с календарём

Для работы с календарём существует специальное API - IBusinessCalendarService и набор хранимых функций и процедур. Для использования API - достаточно отрезолвить его из Unity. Так как в большинстве случаев API календаря используется внутри расширений - достаточно запросить его в конструкторе расширения.

class SomeExtension : CardGetExtension
{
    private readonly IBusinessCalendarService calendarService;

    public CalendarCardButtonsExtension(
        IBusinessCalendarService calendarService)
    {
        this.calendarService = calendarService;
    }
    // ...
}

Далее можно использовать любой из предоставленных в API методов. Так же есть специальный набор хранимых процедур, которые можно использовать внутри sql-запросов.

9.15.4. Проверить - является ли рабочим указанное в UTC дата/время

При помощи функции IsWorkTime - можно проверить, является ли указанный момент времени рабочим.

    /// <summary>
    /// Проверить - является ли рабочим указанное дата\время в UTC.
    /// </summary>
    /// <param name="dateTimeUTC">Дата\время в UTC.</param>
    /// <returns> <see cref="BusinessCalendarTimeType"/>
    ///  Work - рабочее время.
    ///  Holiday - нерабочее время.</returns>
    BusinessCalendarTimeType IsWorkTime(DateTime dateTimeUTC);

Функция принимает параметр dateTimeUTC DateTime и на выходе возвращает значение перечисления BusinessCalendarTimeType.

public enum BusinessCalendarTimeType
    {
        Work,   // Рабочее время
        Holiday // Нерабочее время
    }

Так же можно воспользоваться хранимой функцией CalendarIsWorkTime.

FUNCTION CalendarIsWorkTime(@DateTimeUtc datetime) RETURNS bit

Функция принимает параметры аналогично методу из API:

  • @DateTimeUtc - дата\время в UTC, для которой производится расчёт.

  • Возвращает - 0 - рабочее время, 1 - нерабочее время.

Описание логики работы

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

Для примера возьмём - 2014-01-01 08:49:00 (момент времени указывается в UTC).

Для того, чтобы понять является ли момент времени 2014-01-01 08:49:00 рабочим, нужно запросить из таблицы календаря квант, в который этот момент времени попадает. По типу полученного кванта можно будет понять, является ли момент времени рабочим или нет.

Если мы обратимся к таблице (см. выше), то мы увидим, что момент времени 2014-01-01 08:49:00 соответствует кванту с номером 16, который начинается 2014-01-01 08:45:00 и длится до 2014-01-01 09:00:00, а его тип - 0. Значит квант является квантом рабочего времени и момент времени 2014-01-01 08:49:00 является рабочим временем.

9.15.5. Расчёт рабочего времени между датами.

При помощи функции GetDateDiff - можно получить количество рабочих квантов между двумя моментами времени.

    /// Расчёт рабочего времени между датами.
    /// </summary>
    /// <param name="dateTimeStartUTC">Первая дата в UTC.</param>
    /// <param name="dateTimeEndUTC">Вторая дата в UTC (должна быть больше первой).</param>
    /// <returns>Возвращает рабочее время между датами в квантах.</returns>
    long GetDateDiff(DateTime dateTimeStartUTC, DateTime dateTimeEndUTC);

Функция принимает два параметра dateTimeStartUTC и dateTimeEndUTC (начало и конец соответственно) типа DateTime и на выходе возвращает количество рабочих квантов между этими двумя моментами времени.

Так же можно воспользоваться хранимой функцией CalendarGetDateDiff.

FUNCTION CalendarGetDateDiff(@FirstDateUtc datetime, @SecondDateUtc datetime) RETURNS int

Функция принимает данные аналогично методу из API:

  • @FirstDateUtc - первая дата в UTC.

  • @SecondDateUtc - вторая дата в UTC.

  • Возвращает количество рабочих квантов между датами (1 квант = 15 минут).

Описание логики работы

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

Для примера возьмём моменты времени - 2014-01-01 05:29:00 и 2014-01-01 11:20:00 (моменты времени указываются в UTC). Для того, чтобы найти количество рабочих квантов между двумя моментами времени (2014-01-01 05:29:00 и 2014-01-01 11:20:00) необходимо найти два кванта, которые относятся к первому и второму моменту времени соответственно. 

Если мы обратимся к таблице (см. выше), то увидим, что это кванты с номерами 2 и 18 соответственно. А затем вычесть из номера кванта второго момента времени - номер кванта первого момента времени, получится 18-2=16. Полученная разница будет являться количеством рабочих квантов между двумя моментами времени. Так как 1 рабочий квант это 15 минут, то между моментами времени 2014-01-01 05:29:00 и 2014-01-01 11:20:00 будет 4 рабочих часа.

9.15.6. Отсчёт в квантах рабочего времени от указанной даты

При помощи функции AddWorkingQuantsToDate - можно получить ближайшее рабочее время, которое наступит, если добавить к моменту времени некоторое количество рабочих квантов.

/// <summary>
    /// Отсчёт рабочего времени от указанной даты.
    /// </summary>
    /// <param name="dateTimeUTC">Дата\время в UTC, к которому производится добавление.</param>
    /// <param name="quants">рабочее время в квантах.</param>
    /// <returns>Возвращает дату\время в UTC. Дата округляется до 15 минут в большую сторону.</returns>
    DateTime AddWorkingQuantsToDate(DateTime dateTimeUTC, long quants);

Функция принимает параметр dateTimeUTC типа DateTime - момент времени, от которого необходимо начинать отсчёт и параметр quants типа long - количество рабочих квантов, которые необходимо добавить. На выходе возвращает ближайшее рабочее время, которое наступит, если добавить к моменту времени dateTimeUTC рабочие кванты в количестве quants.

Так же можно воспользоваться хранимой функцией CalendarAddWorkQuants.

    FUNCTION CalendarAddWorkQuants(@DateUtc datetime, @Quants int) RETURNS datetime

Функция принимает данные аналогично методу из API:

  • @DateUtc - дата\время в UTC, к которому производится добавление.

  • @Quants - рабочее время в квантах (1 квант = 15 минут).

  • Возвращает дата\время в UTC. Дата округляется до 15 минут в большую сторону.

Описание логики работы

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

Для этого мы делаем соответствующий запрос к API или вызываем хранимую функцию. Не забываем, что при обращении к календарю мы оперируем рабочими квантами (интервалами по 15 минут).

Для примера возьмём моменты времени - 2014-01-01 05:25:00 (момент времени указывается в UTC) и 5 рабочих часов (20 рабочих квантов). Для того, чтобы найти момент времени в ближайшем рабочем дне, когда пройдут 5 рабочих часов относительно момента времени 2014-01-01 05:25:00 нужно для начала найти квант, к которому относится выбранный момент времени. 

Если мы обратимся к таблице (см. выше), то увидим, что это квант с номером 2. Далее прибавим 20 квантов (5 часов) к 2-ум и получим 22. Найдём квант с номером 22 и типом 0. Начало этого кванта будет искомым моментом времени 2014-01-01 12:30:00.

9.15.7. Отсчёт в рабочих днях от указанной даты

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

    /// <summary>
    /// Получение даты рабочего времени смещением  в рабочих днях относительно заданной даты.
    /// </summary>
    /// <param name="dateTimeUTC">Дата в UTC.</param>
    /// <param name="daysOffset">Смещение в рабочих днях.</param>
    /// <returns>Возвращает дату рабочего времени.</returns>
    DateTime AddWorkingDaysToDate(DateTime dateTimeUTC, double daysOffset);

Функция принимает параметр dateTimeUTC типа DateTime - момент времени, от которого необходимо начинать отсчёт и параметр daysOffset типа double - количество рабочих дней, которые необходимо добавить. На выходе возвращает ближайшее рабочее время, которое наступит, если добавить к моменту времени dateTimeUTC рабочие дни в количестве daysOffset.

Так же можно воспользоваться хранимой функцией CalendarAddWorkingDaysToDate.

    FUNCTION CalendarAddWorkingDaysToDate(@DateTimeUtc datetime, @Offset float) RETURNS datetime

Функция принимает данные аналогично методу из API:

  • @DateTimeUtc - дата\время в UTC, к которому производится добавление.

  • @Offset - рабочее время в днях.

  • Возвращает дата\время в UTC. Дата округляется до 15 минут в большую сторону.

Описание логики работы

Допустим, нам необходимо узнать к какой дате будет завешено задание, если начать его выполнение сейчас и на его выполнение нужно какое-то количество рабочих дней. 

Для этого мы делаем соответствующий запрос к API или вызываем хранимую функцию. Не забываем, что при обращении к календарю мы оперируем рабочими квантами (интервалами по 15 минут).

Для примера возьмём моменты времени - 2014-01-01 05:25:00 (момент времени указывается в UTC) и 1,5 рабочих дней. Для того, чтобы найти момент времени в ближайшем рабочем дне, когда пройдут 1,5 рабочих дня относительно момента времени 2014-01-01 05:25:00 нужно для начала найти количество квантов в 1,5 рабочих днях. (32 * 1,5 = 48, исходя из 8-ми часового рабочего дня). Следовательно, должно пройти 48 рабочих кванта, с момента времени 2014-01-01 05:25:00

Если мы обратимся к таблице (см. выше), то увидим, что это квант с номером 2. Далее прибавим 48 квантов (1,5 дня) к 2-ум и получим 50. Найдём квант с номером 50 и типом 0. Начало этого кванта будет искомым моментом времени 2014-01-06 11:15:00.

9.15.8. Отсчёт в фактических рабочих днях от указанной даты

При помощи функции AddWorkingDaysToDateExact - можно получить ближайшее рабочее время, которое наступит, если добавить к моменту времени некоторое количество фактических рабочих дней.

    /// <summary>
    /// Добавление нужного количества рабочих дней к дате.
    /// </summary>
    /// <param name="dateTimeUTC">Дата в UTC.</param>
    /// <param name="interval">Количество рабочих дней.</param>
    /// <returns>Возвращает дату рабочего времени.</returns>
    DateTime CalendarAddWorkingDaysToDateExact(DateTime dateTimeUTC, int interval);

Функция принимает параметр dateTimeUTC типа DateTime - момент времени, от которого необходимо начинать отсчёт и параметр daysOffset типа int - количество рабочих дней, которые необходимо добавить. На выходе возвращает ближайшее рабочее время, которое наступит, если добавить к моменту времени dateTimeUTC рабочие дни в количестве interval.

Так же можно воспользоваться хранимой функцией CalendarAddWorkingDaysToDateExact.

    FUNCTION CalendarAddWorkingDaysToDateExact(@dateTimeUTC datetime, @Interval int) RETURNS datetime

Функция принимает данные аналогично методу из API:

  • @dateTimeUTC - дата\время в UTC, к которому производится добавление.

  • @Interval - рабочее время в днях.

  • Возвращает дата\время в UTC. Дата округляется до 15 минут в большую сторону.

Описание логики работы

Берем подмножество квантов календаря за период, в котором гарантированно окажется нужно количество рабочих дней. Эмпирически это (нужное кол-во рабочих дней)*3+14. 14 - это самый длинные в мире каникулы (новогодние). Такая фильтрация нужна, чтобы не обрабатывать весь календарь, что ОЧЕНЬ медленно и ОЧЕНЬ тяжело для сервера. Выбираем все рабочие кванты за этот период. В итоге мы получаем, что на каждый рабочий день есть хотя бы один квант. Конвертируем дату\время начала кванта в дату и делаем дистинкт - т.е. у нас остается всего по одной строке на каждый рабочий день. В итоге мы получили список рабочих дней в этом периоде. Сортируем, нумеруем и выбираем нужный по номеру = (нужное кол-во рабочих дней).

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

При помощи функции GetFirstQuantStart - можно получить начало первого рабочего кванта рабочего дня, полученного смещением относительно заданной даты.

    /// <summary>
    /// Получение начала первого рабочего кванта рабочего дня,
    /// полученного смещением относительно заданной даты.
    /// </summary>
    /// <param name="dateTimeUTC">Дата в UTC.</param>
    /// <param name="daysOffset">Смещение в рабочих днях.</param>
    /// <returns>Возвращает дату\время в UTC начала первого рабочего кванта рабочего дня.</returns>
    DateTime GetFirstQuantStart(DateTime dateTimeUTC, int daysOffset);

Функция принимает параметр dateTimeUTC типа DateTime - момент времени, от которого необходимо выполнять смещение и параметр daysOffset типа int количество дней, на которые необходимо произвести смещение. На выходе возвращает момент времени, в который начинается ближайший рабочий день. Параметр daysOffset может иметь отрицательное значение, а если он равен нулю, то будет получено начало ближайшего рабочего дня.

Пример использования:

var dayStart = calendarService.GetFirstQuantStart(DateTime.UtcNow, -5);

Так же можно воспользоваться хранимой функцией CalendarGetFirstQuantStart.

FUNCTION CalendarGetFirstQuantStart(@DateTimeUtc datetime, @Offset int) RETURNS datetime

Функция принимает данные аналогично методу из API.

  • @DateTimeUtc - дата\время в UTC, для которой производится расчёт.

  • @Offset - смещение в днях.

  • Возвращает дата/время начала рабочего дня.

Описание логики работы

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

Для примера возьмём моменты времени - 2014-01-01 05:25:00 (момент времени указывается в UTC) и смещение в 1 рабочий день. Для того, чтобы найти начало ближайшего дня смещением относительно заданного момента времени на один день - нужно найти первый рабочий квант, захватывающий момент времени 2014-01-01 05:25:00 + 1 день.

Если мы обратимся к таблице (см. выше), то увидим, что это квант с номером 33. Начало этого кванта будет искомым моментом времени 2014-01-06 05:00:00.

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

При помощи функции GetLastQuantEnd - можно получить конец первого рабочего кванта рабочего дня, полученного смещением относительно заданной даты.

    /// <summary>
    /// Получение конца последнего рабочего кванта рабочего дня,
    /// полученного смещением относительно заданной даты.
    /// </summary>
    /// <param name="dateTimeUTC">Дата в UTC.</param>
    /// <param name="daysOffset">Смещение в рабочих днях.</param>
    /// <returns>Возвращает дату\время в UTC конца последнего рабочего кванта рабочего дня.</returns>
    DateTime GetLastQuantEnd(DateTime dateTimeUTC, int daysOffset);

Функция принимает параметр dateTimeUTC типа DateTime - момент времени, от которого необходимо выполнять смещение и параметр daysOffset типа int количество дней, на которые необходимо произвести смещение. На выходе возвращает момент времени, в который заканчивается ближайший рабочий день. Параметр daysOffset может иметь отрицательное значение, а если он равен нулю, то будет получен конец ближайшего рабочего дня.

Пример использования:

var dayEnd = calendarService.GetLastQuantEnd(DateTime.UtcNow, 0);

Так же можно воспользоваться хранимой функцией CalendarGetLastQuantStart.

FUNCTION CalendarGetLastQuantStart(@DateTimeUtc datetime, @Offset int) RETURNS datetime

Функция принимает данные аналогично методу из API.

  • @DateTimeUtc - дата\время в UTC, для которой производится расчёт.

  • @Offset - смещение в днях.

  • Возвращает - дата/время начала рабочего дня.

Описание логики работы

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

Для примера возьмём моменты времени - 2014-01-01 05:25:00 (момент времени указывается в UTC) и смещение в 1 рабочий день. Для того, чтобы найти конец ближайшего дня смещением относительно заданного момента времени на один день - нужно найти первый рабочий квант, захватывающий момент времени 2014-01-01 05:25:00 + 1 день. Начало этого кванта будет 2014-01-06 05:00:00. Затем найти начало следующего дня при помощи полученных данных. Это 2014-01-07 00:00:00. Далее найдём все рабочие кванты начало которых больше 2014-01-06 05:00:00, но меньше 2014-01-07 00:00:00 и, отсортировав по убыванию возьмём первый из списка квант. Это будет квант с номером 64 и типом 0. Его время окончания и будет искомым значением 2014-01-06 15:00:00.

9.15.11. Служебные методы

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

Перестраивание календаря

При помощи функции RebuildCalendar - можно вызвать пересчёт календаря аналогично тому, как календарь перестраивается из карточки.

    /// <summary>
    /// Вызывает перестройку календаря на основе внесённых исключений.
    /// </summary>
    /// <param name="operationGuid">Guid операции.</param>
    /// <param name="dateTimeStartUTC">Первая дата в UTC.</param>
    /// <param name="dateTimeEndUTC">Вторая дата в UTC (должна быть больше первой).</param>
    /// <param name="workTimeStartUTC">Начало рабочего дня.</param>
    /// <param name="workTimeEndUTC">Конец рабочего дня.</param>
    /// <param name="lunchTimeStartUTC">Начало обеда.</param>
    /// <param name="lunchTimeEndUTC">Конец обеда.</param>
    void RebuildCalendar(
        Guid operationGuid,
        DateTime dateTimeStartUTC,
        DateTime dateTimeEndUTC,
        DateTime workTimeStartUTC,
        DateTime workTimeEndUTC,
        DateTime lunchTimeStartUTC,
        DateTime lunchTimeEndUTC);

Функция реализована с использованием API операций и принимает параметр operationGuid типа Guid - ID операции, а так же параметры начала/конца рабочего дня, начала/конца обеденного перерыва и период действия календаря.

Хранимая процедура CalendarPrepareQuants:

CREATE PROCEDURE CalendarPrepareQuants
(
    @PeriodStartTimeUTC datetime, -- дата\время, начиная с которой должны быть перерасчитаны кванты
    @PeriodEndTimeUTC datetime, -- дата\время, до которой должные быть перерасчитаны кванты.
    @WorkDayStartTimeUTC datetime, -- время начала рабочего дня.
    @WorkDayEndTimeUTC datetime, -- время конца рабочего дня.
    @LaunchStartTimeUTC datetime, -- время начала обеда.
    @LaunchEndTimeUTC datetime-- время конца обеда.
)

9.15.12. Проверка календаря на наличие ошибок

При помощи функции ValidateCalendar - можно проверить календарь на наличие ошибок.

    /// <summary>
    /// Проверяет календарь на отсутствие пропусков между квантами.
    /// </summary>
    /// <param name="message">Сообщение с кратким отчётом.</param>
    /// <returns>Признак наличия ошибок.</returns>
    bool ValidateCalendar(out string message);

Функция out принимает параметр message типа string, в который записывается краткий отчёт. На выходе идёт признак наличия ошибок. 

Пример использования
string message = "";
var isErrors = calendarService.ValidateCalendar(out message);

Проверка календаря на наличие ошибок реализована исключительно как функция API и не имеет своей хранимой процедуры или функции.

9.15.13. Применение календаря на больших выборках/отчётах

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

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

select ((cqu2.QuantNumber - cqu1.QuantNumber)/4.0) as WorkHoursLeft, *
from Tasks as tsk with(nolock)
outer apply
(
    select top 1 q.QuantNumber
    from CalendarQuants q with(nolock)
    where q.StartTimeUTC <= tsk.Created
    order by q.StartTimeUTC desc
) as cqu1
outer apply
(
    select top 1 q.QuantNumber
    from CalendarQuants q with(nolock)
    where q.StartTimeUTC <= tsk.Planned
    order by q.StartTimeUTC desc
) as cqu2

Пожалуйста, не используйте вариант запроса, представленный ниже. Несмотря на свою внешнюю простоту, он выполняется в тясячи раз медленнее!

Неправильный запрос
select ((cqu2.QuantNumber - cqu1.QuantNumber)/4.0) as WorkHoursLeft, *
from Tasks as tsk with(nolock)
left join CalendarQuants as cqu1 with(nolock) on cqu1.StartTimeUTC <= tsk.Created and cqu1.EndTimeUTC > tsk.Created
left join CalendarQuants as cqu2 with(nolock) on cqu2.StartTimeUTC <= tsk.Planned and cqu2.EndTimeUTC > tsk.Planned

Причина лучшей производительности первого варианта

И в первом и во втором случае используется поиск по кластерному индексу:

--Первый вариант
Clustered Index Seek(
 OBJECT:([tessa].[dbo].[CalendarQuants].[idx_CalendarQuants_StartTimeUTCEndTimeUTC] AS [q]),
 SEEK:([q].[StartTimeUTC] <= [tessa].[dbo].[Tasks].[Planned] as [tsk].[Planned]) ORDERED FORWARD)

--Второй вариант
Clustered Index Seek(
 OBJECT:([tessa].[dbo].[CalendarQuants].[idx_CalendarQuants_StartTimeUTCEndTimeUTC] AS [cqu2]),
 SEEK:([cqu2].[StartTimeUTC] <= [tessa].[dbo].[Tasks].[Planned] as [tsk].[Planned]),
 WHERE:([tessa].[dbo].[CalendarQuants].[EndTimeUTC] as [cqu2].[EndTimeUTC]>[tessa].[dbo].[Tasks].[Planned] as [tsk].[Planned]) ORDERED FORWARD)

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

9.16. Создание карточки-сателлита

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

Сателлит даёт следующие преимущества:

  • Можно изменять связанные с карточкой данные, не изменяя версию карточки.

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

  • Можно связать данные с типом карточки, не изменяя его структуру. Это позволяет иметь нестандартное API (такое, как ролевая модель), которое продолжит корректно взаимодействовать с основной карточкой.

  • Неизменность структуры основной карточки также обеспечивает бинарную совместимость этой карточки. Т.е. ранее удалённые или экспортированные карточки без сателлита по-прежнему можно будет восстановить.

Для карточки-сателлита необходимо обеспечить:

  • Создание по первому требованию со стороны расширений. Это может быть старт бизнес-процесса или явный запрос настроек сотрудника при старте приложения.

  • Загрузка по идентификатору основной карточки. Писатели расширений должны легко получить данные сателлита как на клиенте, так и на сервере, зная только идентификатор основной карточки.

  • Автоматическое удаление при удалении основной карточки.

  • Автоматический экспорт при экспорте основной карточки.

  • Автоматический импорт при импорте основной карточки.

  • Восстановление из корзины вместе с восстановлением основной карточки.

9.16.1. Сателлит с настройками сотрудника

Рассмотрим подробнее процесс создания карточки-сателлита PersonalRoleSatellite с настройками сотрудника по его рабочим местам.

Структура карточки:

PersonalRoleSatellite (entry)
    MainCardID : Guid              // ссылка на карточку сотрудника
    WorkplaceExtensions: byte[]    // сериализованные настройки рабочего места или null, если настройки пока не заданы

Напишем некоторые полезные хэлперы в классе RoleExtensionHelper:

  • GetPersonalRoleSatelliteID - определяет идентификатор карточки-сателлита по идентификатору сотрудника, выполняя запрос в базе.

    public static Guid? GetPersonalRoleSatelliteID(IDbScope dbScope, Guid personalRoleID)
    {
        DbManager db = dbScope.Db;
    
        return db
            .SetCommand(
                "SELECT [ID]"
                + " FROM [dbo].[PersonalRoleSatellite] with(nolock)"
                + " WHERE [MainCardID] = @MainCardID",
                db.Parameter("@MainCardID", SqlHelper.NotNull(personalRoleID)))
            .LogCommand()
            .ExecuteScalar<Guid?>();
    }
  • SetPersonalRoleSatellite - "зашивает" пакет карточки-сателлита satellite в Info карточки сотрудника personalRole. Метод нужен для того, чтобы сериализовать сателлит вместе с данными основной карточки. Таким образом, сателлит можно будет восстановить вместе с карточкой сотрудника из сериализованного состояния, в котором они пребывают в корзине. Сателлит доступен как хеш-таблица (метод GetStorage()) по строковому ключу PersonalRoleSatelliteKey, префикс которого SystemKeyPrefix устанавливается только для системных карточек. Для любых других карточек ключ следует задавать без префикса как "MySatellite".

    private const string PersonalRoleSatelliteKey = CardHelper.SystemKeyPrefix + "satellite";
    
    public static void SetPersonalRoleSatellite(Card personalRole, Card satellite)
    {
        personalRole.Info[PersonalRoleSatelliteKey] =
            satellite != null ? satellite.GetStorage() : null;
    
        ThreadCache<Card, Card>
            .Reset(personalRole);
    }
  • SatelliteWasNotFound - возвращает признак того, что в Info карточки сотрудника personalRole методом SetPersonalRoleSatellite сателлит был явно установлен как null. Таким образом, можно будет отличить состояния "поиск сателлита был выполнен, и сателлит не найден" (установлен в null) от "поиск сателлита ещё не был выполнен" (ключ отсутствует в хеш-таблице).

    public static bool SatelliteWasNotFound(Card personalRole)
    {
        object value;
        return personalRole.Info.TryGetValue(PersonalRoleSatelliteKey, out value)
               && value == null;
    }
  • TryGetPersonalRoleSatellite - возвращает карточку сателлита, ранее установленную методом SetPersonalRoleSatellite в Info заданной карточки сотрудника personalRole. Метод возвращает null, если сателлит не был установлен или же был установлен как null (см. метод SatelliteWasNotFound). Отметим, что возвращается объект Card, а в Info задана хеш-таблица Dictionary<string, object>. Чтобы объект Card по возможности создавался один раз для хеш-таблицы, используется ThreadCache, которому передаётся фабрика, создающая объект Card по хеш-таблице, причём фабрика будет вызвана только в том случае, если этот метод ещё не вызывается для объекта personalRole, или же он был вызван для карточки, отличной от personalRole.

    public static Card TryGetPersonalRoleSatellite(Card personalRole)
    {
        Dictionary<string, object> info = personalRole.TryGetInfo();
        if (info == null)
        {
            return null;
        }
    
        return ThreadCache<Card, Card>
            .Get(personalRole, card =>
            {
                object value;
                if (!card.Info.TryGetValue(PersonalRoleSatelliteKey, out value))
                {
                    return null;
                }
    
                var dictionary = value as Dictionary<string, object>;
                return dictionary != null ? new Card(dictionary) : null;
            });
    }

Вспомогательные методы можно упростить, если использовать API из Tessa.Cards.Extensions.Templates:

public static Guid? GetPersonalRoleSatelliteID(IDbScope dbScope, Guid personalRoleID)
{
    return CardSatelliteHelper.TryGetSatelliteID(dbScope, personalRoleID, "PersonalRoleSatellite");
}

public static void SetPersonalRoleSatellite(Card personalRole, Card satellite)
{
    CardSatelliteHelper.SetSatellite(personalRole, satellite);
}

public static bool SatelliteWasNotFound(Card personalRole)
{
    return CardSatelliteHelper.SatelliteCardWasNotFound(personalRole);
}

public static Card TryGetPersonalRoleSatellite(Card personalRole)
{
    return CardSatelliteHelper.TryGetSatelliteCard(personalRole);
}

9.16.2. Расширение на создание и загрузку сателлита

Все расширения на сателлит делаем серверными, чтобы с сателлитом можно было работать как на клиенте, так и на сервере.

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

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

  1. При наличии сателлита подменяем ID загружаемой карточки на ID сателлита, чтобы стандартное API штатным образом прочитало сателлит.

  2. При отсутствии сателлита ставим блокировку без проверки версии на запись карточки сотрудника, для которого нужен сателлит. Одновременно захватить блокировку может не более одного процесса загрузки.

  3. Внутри блокировки ещё раз проверяем наличие сателлита, и если он есть - также поменяем ID. Это может произойти, если блокировка на запись ждала другой процесс, который уже создал сателлит параллельно с ожиданием, т.е. позже проверки из п.1.

  4. Создаём пустой пакет карточки-сателлита, в котором заполняем поле MainCardID, а остальные поля оставляем по умолчанию.

  5. Охраняем сателлит (т.е. создаём его в базе) вместе с расширениями, а потом уже подменяем ID, чтобы сателлит был загружен штатным образом.

Класс расширения:

public sealed class PersonalRoleSatelliteGetExtension : CardGetExtension
{
    private readonly ICardRepository cardRepository;

    private readonly ICardTransactionStrategy cardTransactionStrategy;

    private readonly IDbScope dbScope;

    public PersonalRoleSatelliteGetExtension(
        ICardRepository cardRepository,
        ICardTransactionStrategy cardTransactionStrategy,
        IDbScope dbScope)
    {
        this.cardRepository = cardRepository;
        this.cardTransactionStrategy = cardTransactionStrategy;
        this.dbScope = dbScope;
    }

    public override void BeforeRequest(ICardGetExtensionContext context)
    {
        Guid? cardID = context.Request.CardID;
        if (!cardID.HasValue)
        {
            return;
        }

        // dbScope гарантирует наличие не более одного соединения с базой данных
        using (this.dbScope.Create())
        {
            Guid? satelliteID = RoleExtensionHelper.TryGetPersonalRoleSatelliteID(this.dbScope, cardID.Value);
            if (satelliteID.HasValue)
            {
                context.Request.CardID = satelliteID;
                return;
            }

            // блокируем карточку сотрудника, чтобы гарантировать то, что не будет создано более одной карточки сателлита
            this.cardTransactionStrategy.ExecuteInWriterLock(
                cardID.Value,
                CardComponentHelper.DoNotCheckVersion,
                context.ValidationResult,
                p =>
                {
                    // проверяем наличие сателлита внутри блокировки, т.к. сателлит уже мог быть создан
                    // одновременным запросом, пока ожидалась блокировка
                    Guid? transactionSatelliteID = RoleExtensionHelper.TryGetPersonalRoleSatelliteID(this.dbScope, cardID.Value);
                    if (transactionSatelliteID.HasValue)
                    {
                        context.Request.CardID = transactionSatelliteID;
                        return;
                    }

                    // создаём пакет карточки сателлита
                    CardNewResponse newResponse =
                        this.cardRepository.New(new CardNewRequest
                        {
                            CardTypeID = RoleHelper.PersonalRoleSatelliteTypeID,
                            NewMode = CardNewMode.Valid
                        });

                    context.ValidationResult.Add(newResponse.ValidationResult);
                    if (!context.ValidationResult.IsSuccessful())
                    {
                        return;
                    }

                    // заполняем пакет карточки
                    Card card = newResponse.Card;
                    Guid newSatelliteID = Guid.NewGuid();
                    card.ID = newSatelliteID;
                    card.Sections["PersonalRoleSatellite"].RawFields["MainCardID"] = cardID.Value;

                    // сохраняем сателлит в базу
                    CardStoreResponse storeResponse =
                        this.cardRepository.Store(new CardStoreRequest
                        {
                            Card = card
                        });

                    context.ValidationResult.Add(storeResponse.ValidationResult);
                    if (!context.ValidationResult.IsSuccessful())
                    {
                        return;
                    }

                    context.Request.CardID = newSatelliteID;
                });
        }
    }
}

Расширение можно упростить, если использовать шаблон из Tessa.Cards.Extensions.Templates:

public sealed class PersonalRoleSatelliteGetExtension : CardSatelliteGetExtension
{
    public PersonalRoleSatelliteGetExtension(
        ICardRepository cardRepository,
        ICardTransactionStrategy cardTransactionStrategy,
        IDbScope dbScope)
        : base(cardRepository, cardTransactionStrategy)
    {
    }

    protected override Guid SatelliteTypeID
    {
        get { return RoleHelper.PersonalRoleSatelliteTypeID; }
    }

    protected override Guid? TryGetSatelliteID(IDbScope dbScope, Guid mainCardID)
    {
        return RoleExtensionHelper.TryGetPersonalRoleSatelliteID(dbScope, mainCardID);
    }

    protected override void SetSatelliteMainCardID(Card satellite, Guid mainCardID)
    {
        satellite.Sections["PersonalRoleSatellite"].RawFields["MainCardID"] = mainCardID;
    }
}

Регистрируем расширение, указывая идентификатор типа карточки сателлита RoleHelper.PersonalRoleSatelliteTypeID:

extensions
    .RegisterExtension<ICardGetExtension, PersonalRoleSatelliteGetExtension>(x => x
        .WithOrder(ExtensionStage.AfterPlatform)
        .WithUnity(unityContainer
            .RegisterType<PersonalRoleSatelliteGetExtension>(new ContainerControlledLifetimeManager()))
        .WhenCardTypes(RoleHelper.PersonalRoleSatelliteTypeID));

Использование расширения на сервере:

using Tessa.Cards;
using Tessa.Platform.Storage;
using Tessa.Roles;

ICardRepository cardRepository = unityContainer.Resolve<ICardRepository>();
Guid userID = ... ; // идентификатор сотрудника, для которого нужен сателлит

CardGetResponse response = cardRepository.Get(new CardGetRequest
{
    CardID = userID,
    CardTypeID = RoleHelper.PersonalRoleSatelliteTypeID     // идент