В различных приложениях регулярно возникает задача по поддержке логики изменения во времени некоторого атрибута объекта относительно некоторого субъекта (или субъектов). Например, это может быть изменение розничной цены товара в магазинах или показателей KPI для сотрудников.
В этой статье я покажу, какую доменную логику и интерфейсы можно построить для решения этой задачи. Сразу оговорюсь, что речь будет касаться именно управленческого воздействия пользователем на атрибут, а не отражения исторического изменения.
Реализация будет представлена на базе открытой и бесплатной платформы lsFusion, но подобную схему можно применять и при использовании любой другой технологии.
Введение
Для более простого изложения и понимания статьи в качестве атрибута возьмем цену, в качестве объекта — товар, а субъектом будет склад. При этом минимальным возможным интервалом задания атрибута будет дата. Таким образом, пользователь сможет определять, какая будет на конкретную дату цена для любого товара и склада.
Схема ввода пользователем изменений цены будет похожа на ту, которая используется в классических системах контроля версий. Любое изменение, с точки зрения доменной логики, будет представлять собой один коммит, на основе которых будет высчитываться состояние на определенную дату. Во многих предметных областях такие коммиты называют документами или транзакциями. В данном случае, под этим коммитом будем подразумевать так называемый прайс-лист. В каждом прайс-листе будут задаваться товары и склады, которые в него входят, а также период действия.
Описанная схема имеет следующие преимущества:
- Атомарность. Каждое изменение оформлено как отдельный документ. Таким образом эти документы можно временно сохранять, но не проводить. При ошибочном вводе легко откатить все изменение.
- Прозрачность. Легко определить, кто и когда сделал изменение, а также указать причину, внеся ее в комментарий к документу.
Основное отличие от системы контроля версий в том, что коммиты в явную не зависят друг от друга. Таким образом, можно относительно безболезненно удалять все коммиты в любой момент времени. Кроме того, у каждого такого коммита может быть задана окончания, когда он перестает действовать, чего конечно же нету в системе контроля версий.
Реализация
Определение доменной логики начнем со складов. Немного усложним решение, объединив склады в иерархию группы динамической глубины. По какому принципу это делается описано в соответствующей статье, поэтому просто приведу фрагмент кода, который объявляет группы и создает формы по их редактированию:
CLASS Group 'Группа складов'; |
Дальше объявим склады, которые могут быть привязаны к любой из групп:
CLASS Stock 'Склад'; |
И, наконец, объявим логику товаров:
CLASS Product 'Товар'; |
Перейдем непосредственно к созданию логики прайс-листов. Сначала зададим сам класс Прайс-лист, а также период его действия:
CLASS PriceList 'Прайс-лист'; |
Считаем, что если Дата по не задана, то прайс-лист действует бесконечно.
Добавим событие, которое будет при создании прайс-листа автоматически проставлять текущей дату, с которой он начнет действовать.
WHEN LOCAL SET(PriceList p IS PriceList) DO |
Ключевое слово LOCAL означает, что событие будет срабатывать не в момент применения сохранения в базу данных, а непосредственно в момент изменения.
Затем добавим пользователя, который его создал, и время создания:
createdTime 'Время создания' = DATA DATETIME (PriceList); |
Теперь создадим событие, которое будет автоматически их заполнять:
WHEN SET(PriceList p IS PriceList) DO { |
Это событие, в отличие от предыдущего, будет срабатывать только в тот момент, когда будет нажата кнопка Сохранить. То есть во время транзакции сохранения в базу данных.
Дальше создадим строки прайс-листа, в которых будут заданы товары и цены:
CLASS PriceListDetail 'Строка прайс-листа'; |
Атрибут NONULL указывает на то, что свойство priceList всегда должно быть задано, а DELETE — что при обнулении значения свойства (например, при удалении прайс-листа), нужно автоматически удалить соответствующую строку.
Для последующего использования создадим свойства, которые будут определять период действия строк прайс-листов:
fromDate 'Дата с' (PriceListDetail d) = fromDate(priceList(d)); |
Теперь сделаем привязку прайс-листа к складам, для которых он будет действовать. Вначале добавим первичное свойство, которое будет истинно, если в прайс-лист включена вся группа складов целиком:
dataIn 'Вкл' = DATA BOOLEAN (PriceList, Group); |
Посчитаем «включенность» группы с учетом выбранных родителей (как было описано в статье про иерархии):
in 'Вкл (итого)' (PriceList p, Group child) = |
Добавим первичное свойство, при помощи которого можно указать, что прайс-лист действует на определенный склад:
dataIn 'Вкл' = DATA BOOLEAN (PriceList, Stock); |
Посчитаем итоговое свойство, которое будет определять, что прайс-лист изменяет цены на соответствующем складе с учетом групп:
in 'Вкл' (PriceList p, Stock s) = dataIn(p, s) OR in(p, group(s)); |
Создадим свойство, которое будет показывать названия всех выбранных групп и складов прайс-листа, для более удобного просмотра пользователем списка прайс-листов:
stocks 'Склады' (PriceList p) = CONCAT ' / ', |
Заключительным этапом в описании доменной логики будет непосредственно расчет действующей цены товара на складе. Для этого создается свойство, которое находит последнюю по дате строку прайс-листа с нужным товаров, складом и периодом действия:
priceListDetail (Product p, Stock s, DATE dt) = |
В логике расчета этого свойства возможны различные вариации. Можно изменить как фильтр попадания строк (например, добавив в WHERE условие на то, что прайс-лист проведен), так и порядок. Следует отметить, что в порядок выбора вторым параметром добавлен сам объект, а точнее его внутренний идентификатор. Это нужно, чтобы значение цены всегда определялось однозначным образом.
На основе полученной строки прайс-листа определим значение цены и ее период действия:
price 'Цена' (Product p, Stock s, DATE dt) = price(priceListDetail(p, s, dt)); |
Они будут в дальнейшем использоваться в таблицах пользовательского интерфейса.
Дальше перейдем к построению пользовательского интерфейса. Сначала нарисуем форму по редактированию прайс-листа. Создаем форму и добавляем туда “шапку” документа:
FORM priceList 'Прайс-лист' |
Добавляем на форму строки прайс-листа:
EXTEND FORM priceList |
Дальше добавляем дерево, в которой будут как группы, так и склады:
EXTEND FORM priceList |
Свойства для групп и складов в дерево добавляются одновременно. Платформа будет, в зависимости от объекта, показывать то или иное свойство в порядке их добавления на форму.
Настраиваем дизайн формы, чтобы товары и склады рисовались в отдельные вкладки:
DESIGN priceList { |
Форма редактирования будет выглядеть следующим образом:
Осталось построить основную форму по управлению ценами. Она будет состоять из двух вкладок. На первой будет показываться список всех прайс-листов (по аналогии со списком коммитов). На второй вкладке будут отображаться текущие цены по конкретному складу на выбранную дату.
Для реализации первой вкладки добавим на форму список прайс-листов с строками для быстрого предпросмотра:
FORM managePrices 'Управление ценами' |
Для второй вкладки добавим сначала дату, на которую показывать цены, дерево групп складов, а также сами склады:
EXTEND FORM managePrices |
В списке складов будут показываться все склады, которые являются потомками выбранной вверху группы.
Дальше добавляем на форму список товаров, для которых есть действующие цены по складу на выбранную дату:
EXTEND FORM managePrices |
В колонки добавляются как сама цена, так и период действия. Можно также добавить номер прайс-листа — тогда эта таблица будет напоминать логику аннотаций в системах контроля версий.
Для того, чтобы пользователь понимал откуда взялась такая цена, добавим вниз список строк прайс-листов с подходящими товарами и складами:
EXTEND FORM managePrices |
При помощи атрибута BACKGROUND подсвечиваем строку, которая определила показанную в таблице цену.
Также, для удобства пользователя, добавим возможность сразу из этой истории открывать форму редактирования соответствующего прайс-листа в новой сессии:
edit (PriceListDetail d) + { edit(priceList(d)); } |
Чтобы достичь этого, нужно указать действие, которое будет выполняться при попытке редактирования строки путем имплементации встроенного действия edit. Затем на форму стандартным образом добавляется стандартная кнопка по редактированию объекта через вызов диалога.
И, наконец, формируем итоговый дизайн формы:
DESIGN managePrices { |
Здесь сначала добавляется контейнер pane, который состоит из двух вкладок: priceLists и prices. В первую из них просто добавляются список прайс-листов и строки. Во второй создаются две панели: leftPane и rightPane. Левая панель содержит дату и склады, а правая — товары и историю изменения цен.
Результат
Рассмотрим основные варианты использования получившейся логики.
Предположим, у нас есть два отдельных прайс-листа на разные группы товаров. Тогда, в зависимости от выбранного склада во вкладке с ценами, будут показываться только товары из соответствующих прайс-листов:
Теперь создадим новый прайс-лист с ограниченным периодом действия, урезанным списком складов и новой ценой. На второй вкладке, если мы выберем дату в интервале действия нового прайс-листа, то получим из него новую цену. Как только период действия закончится, то опять вернется старая цена из исходного прайса:
С помощью этого же механизма можно “отменять” действие конкретных цен с определенной даты. Например, если ввести новый прайс, не указав при этом цену, то получится, что цена сбросится, и товар пропадет из фильтра. При этом при удалении введенного документа все возвращается к старому состоянию:
Полученное свойство с ценой товара по складу на дату можно в дальнейшем использовать в различных событиях или других формах. Например, можно сделать автоматическое проставление цены в заказе на основе этой логики определения цены:
WHEN LOCAL CHANGED(sku(UserOrderDetail d)) OR CHANGED(stock(d)) OR CHANGED(dateTime(d)) DO |
Приятным бонусом в такой логике будет то, что при добавлении нового склада в группу, на него будут автоматически распространятся цены из уже созданных прайс-листов. Тоже самое будет происходить и при изменении группы для склада.
При желании можно сделать редактируемой колонку с ценой на вкладке с текущими ценами и добавить кнопку, которая будет создавать новый коммит для измененных цен.
Заключение
В решении на уровне платформы не используются ни справочники, ни документы со строками, ни регистры, ни отчеты и прочие лишние абстракции. Все сделано исключительно на понятиях классов и свойств. Отметим, что эта достаточно сложная логика была реализована приблизительно в 150 значащих строках кода на lsFusion. Реализовать ее в такой же постановке в других платформах (например, 1С) является значительно более сложной задачей.
Описанная выше схема широко используется в ERP-решении на базе lsFusion. При помощи нее с различными модификациями поддерживаются прайс-листы поставщиков, управленческие розничные цены, акции и многие другие управленческие параметры.
Шаблон может быть усложнен путем добавления в документ нескольких субъектов (например, к складу может быть добавлен поставщик), а также определения сразу нескольких атрибутов в одном документе. В частности, можно добавить сущность Вид цены, а в строке документа задавать цену для кортежа строки и соответствующего вида цен. В описанную выше логику нужно будет просто добавить несколько дополнительных параметров в некоторые свойства.
При помощи нескольких дополнительных строк кода существует возможность денормализовать все записи изменений в одну таблицу, на которой построить соответствующий индекс. Тогда выборка любого значения на любую дату будет произведена за логарифмическое время. Такая оптимизация необходима тогда, когда в этой таблице будет находится несколько сот миллионов записей.
Построенный пример можно попробовать онлайн на соответствующей странице сайта (раздел Платформа). Вот исходный код целиком, который нужно вставить в нужное поле:
REQUIRE Authentication, Time;
|
Автор: AntonLn