В различных приложениях регулярно возникает задача по поддержке логики изменения во времени некоторого атрибута объекта относительно некоторого субъекта (или субъектов). Например, это может быть изменение розничной цены товара в магазинах или показателей KPI для сотрудников.
В этой статье я покажу, какую доменную логику и интерфейсы можно построить для решения этой задачи. Сразу оговорюсь, что речь будет касаться именно управленческого воздействия пользователем на атрибут, а не отражения исторического изменения.
Реализация будет представлена на базе открытой и бесплатной платформы lsFusion, но подобную схему можно применять и при использовании любой другой технологии.
Введение
Для более простого изложения и понимания статьи в качестве атрибута возьмем цену, в качестве объекта — товар, а субъектом будет склад. При этом минимальным возможным интервалом задания атрибута будет дата. Таким образом, пользователь сможет определять, какая будет на конкретную дату цена для любого товара и склада.
Схема ввода пользователем изменений цены будет похожа на ту, которая используется в классических системах контроля версий. Любое изменение, с точки зрения доменной логики, будет представлять собой один коммит, на основе которых будет высчитываться состояние на определенную дату. Во многих предметных областях такие коммиты называют документами или транзакциями. В данном случае, под этим коммитом будем подразумевать так называемый прайс-лист. В каждом прайс-листе будут задаваться товары и склады, которые в него входят, а также период действия.
Описанная схема имеет следующие преимущества:
- Атомарность. Каждое изменение оформлено как отдельный документ. Таким образом эти документы можно временно сохранять, но не проводить. При ошибочном вводе легко откатить все изменение.
- Прозрачность. Легко определить, кто и когда сделал изменение, а также указать причину, внеся ее в комментарий к документу.
Основное отличие от системы контроля версий в том, что коммиты в явную не зависят друг от друга. Таким образом, можно относительно безболезненно удалять все коммиты в любой момент времени. Кроме того, у каждого такого коммита может быть задана окончания, когда он перестает действовать, чего конечно же нету в системе контроля версий.
Реализация
Определение доменной логики начнем со складов. Немного усложним решение, объединив склады в иерархию группы динамической глубины. По какому принципу это делается описано в соответствующей статье, поэтому просто приведу фрагмент кода, который объявляет группы и создает формы по их редактированию:
Объявление групп складов
CLASS Group 'Группа складов'; |
Пример иерархии групп
Дальше объявим склады, которые могут быть привязаны к любой из групп:
Объявление складов
CLASS Stock 'Склад'; |
Пример складов
И, наконец, объявим логику товаров:
Объявление товаров
CLASS Product 'Товар'; |
Пример товаров
Перейдем непосредственно к созданию логики прайс-листов. Сначала зададим сам класс Прайс-лист, а также период его действия:
CLASS PriceList 'Прайс-лист'; |
Добавим событие, которое будет при создании прайс-листа автоматически проставлять текущей дату, с которой он начнет действовать.
WHEN LOCAL SET(PriceList p IS PriceList) DO |
Затем добавим пользователя, который его создал, и время создания:
createdTime 'Время создания' = DATA DATETIME (PriceList); |
WHEN SET(PriceList p IS PriceList) DO { |
Дальше создадим строки прайс-листа, в которых будут заданы товары и цены:
CLASS PriceListDetail 'Строка прайс-листа'; |
Для последующего использования создадим свойства, которые будут определять период действия строк прайс-листов:
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) = |
На основе полученной строки прайс-листа определим значение цены и ее период действия:
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 |
Также, для удобства пользователя, добавим возможность сразу из этой истории открывать форму редактирования соответствующего прайс-листа в новой сессии:
edit (PriceListDetail d) + { edit(priceList(d)); } |
И, наконец, формируем итоговый дизайн формы:
DESIGN managePrices { |
Результат
Рассмотрим основные варианты использования получившейся логики.
Предположим, у нас есть два отдельных прайс-листа на разные группы товаров. Тогда, в зависимости от выбранного склада во вкладке с ценами, будут показываться только товары из соответствующих прайс-листов:
Теперь создадим новый прайс-лист с ограниченным периодом действия, урезанным списком складов и новой ценой. На второй вкладке, если мы выберем дату в интервале действия нового прайс-листа, то получим из него новую цену. Как только период действия закончится, то опять вернется старая цена из исходного прайса:
С помощью этого же механизма можно “отменять” действие конкретных цен с определенной даты. Например, если ввести новый прайс, не указав при этом цену, то получится, что цена сбросится, и товар пропадет из фильтра. При этом при удалении введенного документа все возвращается к старому состоянию:
Полученное свойство с ценой товара по складу на дату можно в дальнейшем использовать в различных событиях или других формах. Например, можно сделать автоматическое проставление цены в заказе на основе этой логики определения цены:
WHEN LOCAL CHANGED(sku(UserOrderDetail d)) OR CHANGED(stock(d)) OR CHANGED(dateTime(d)) DO |
При желании можно сделать редактируемой колонку с ценой на вкладке с текущими ценами и добавить кнопку, которая будет создавать новый коммит для измененных цен.
Заключение
В решении на уровне платформы не используются ни справочники, ни документы со строками, ни регистры, ни отчеты и прочие лишние абстракции. Все сделано исключительно на понятиях классов и свойств. Отметим, что эта достаточно сложная логика была реализована приблизительно в 150 значащих строках кода на lsFusion. Реализовать ее в такой же постановке в других платформах (например, 1С) является значительно более сложной задачей.
Описанная выше схема широко используется в ERP-решении на базе lsFusion. При помощи нее с различными модификациями поддерживаются прайс-листы поставщиков, управленческие розничные цены, акции и многие другие управленческие параметры.
Шаблон может быть усложнен путем добавления в документ нескольких субъектов (например, к складу может быть добавлен поставщик), а также определения сразу нескольких атрибутов в одном документе. В частности, можно добавить сущность Вид цены, а в строке документа задавать цену для кортежа строки и соответствующего вида цен. В описанную выше логику нужно будет просто добавить несколько дополнительных параметров в некоторые свойства.
При помощи нескольких дополнительных строк кода существует возможность денормализовать все записи изменений в одну таблицу, на которой построить соответствующий индекс. Тогда выборка любого значения на любую дату будет произведена за логарифмическое время. Такая оптимизация необходима тогда, когда в этой таблице будет находится несколько сот миллионов записей.
Построенный пример можно попробовать онлайн на соответствующей странице сайта (раздел Платформа). Вот исходный код целиком, который нужно вставить в нужное поле:
Исходный код
REQUIRE Authentication, Time; |
gennayo
А если к этой схеме добавить покупателя, для каждого из которых прайс рассчитывается «на лету» на основании некоего набора базовых прайсов, то что и как будет в БД храниться?
AntonLn Автор
Не очень понял, куда именно нужно добавлять покупателя. Для каждого покупателя будут свои прайсы? Если так, то просто добавляется в шапку покупатель, а затем в расчете priceListDetail (Product p, Stock s, DATE dt) добавляется еще один параметр, который затем используется в WHERE. И получаются разные цены для разных покупателей.
gennayo
Да, для каждого покупателя свой прайс. Но я, видимо, взял более широкую задачу, когда прайс-лист не хранится в базе, а динамически рассчитывается в момент обращения на основании других прайслистов, описанная в статье схема не подходит для этих целей.
AntonLn Автор
Можно сделать вот как: есть базовые прайсы как в статье. Дальше, на основе их уже высчитаны текущие цены: см. свойство price (Product, Stock, DATE). Дальше можно его умножать, или делить, например, на скидку покупателю (или любое другое выражение). То есть:
price (Customer c, Product p, Stock st, DATE d) = price(p, st, d) * (100 — discount( c )) / 100;
И дальше использовать эту цену в заказах или где-то еще.
gennayo
Интересно, какое будет быстродействие получения данных в таком случае, если, например, нужно по запросу отдать по API прайсы из 5000 позиций на 50 покупателей, если прайс будет вычисляться по сложной формуле, например, от объема продаж за текущий месяц с учётом возвратов и ещё парочки подобных критериев.
AntonLn Автор
Хорошее быстродействие. Так как там никакого ORM не будет, а все скомпилируется в один (или чуть больше) оптимизированных SQL запросов, которые считают нужные данные.
Правда запросы будут зависеть от расставленных MATERIALIZED. Как я писал в статье: можно денормализовать через MATERIALIZED кортеж из PriceListDetail, Stock в одну таблицу и построить по ней индекс по полям (product, stock, fromDate). Тогда для каждого product и stock PostgreSQL будет находить цену по индексу за логарифм.
Если учитывать другие параметры (объемы продаж и прочее), то тоже можно сделать MATERIALIZED на них, и все будет хранится в таблицах покупателей (или других с небольшим количеством записей) и автоматически обновляться. Тогда к ним будет идти простой JOIN. Ну или не делать все эти MATERIALIZED, тогда СУБД будет их высчитывать в момент запроса (пойдут такие запросы).
У нас на практике в таких денормализованных таблицах хранятся сотни миллионов записей, и работает все достаточно быстро (хотя это относительно конечно). Но сделать быстрее в такой логике вряд ли получится.
gennayo
Хранить каждый возможный критерий в таблице покупателей — так себе идея, мне кажется. Тогда при добавлении новых критериев будет реструктуризация таблиц и пересчет по всем покупателям. Или нет?
AntonLn Автор
Не обязательно каждый возможный. На lsFusion сначала делается логика, а потом уже в эксплуатации добавляются MATERIALIZED на промежуточные свойства, чтобы ускорить чтение, но замедлить чуть чуть запись. Более подробно мы писали вот в этой статье.
Когда добавляется новый MATERIALIZED на старте автоматически создается поле и рассчитывается по нужной формуле. Обычно это идет достаточно быстро, так как этот расчет идет не на сервере приложений, а одним запросом на SQL сервере и делается достаточно быстро (мы на практике добавляем поля как в таблицы с сотнями миллионов записей, так и где расчет зависит от таких таблиц). И это все занимало максимум один час. Добавление пустой колонки в PostgreSQL вообще мгновенная операция.
В любом случае, тут 2 варианта: либо считать в момент запроса, либо при записи инкрементно обновлять значения. Третьего не дано. И в lsFusion это все делается прозрачно, без изменения остальной логики приложения.
gennayo
Понятно. Ещё вопрос не совсем по теме — у вас есть какие-нибудь готовые наработки по реализации геотрекинга и работе с картографической информацией на вашей платформе?
AntonLn Автор
К сожалению, пока ничего нет в это части. Мы пока акцентируем внимание на бизнес-приложениях и информационных системах, но для таких вещей есть из коробки JSON API, возможность прямо из lsFusion кода делать HTTP вызовы, и возможность спускаться на уровень ниже и писать на Java.
gennayo
Жаль, есть в перспективе задача быстро набросать прототип такой системы на какой-то бесплатной платформе, без необходимости спускаться до уровня Java.
prolis
Критерий в таблице покупателей хранить не надо. Можно сегментировать клиентов по этим критериям (и пересегментировать при необходимости). И в расчёте цены применять ценочувствительные сегменты клиента, а не его свойства.
gennayo
Можно и так, конечно, но в предельном случае каждый сегмент будет состоять из одного клиента :)
AntonLn Автор
Кстати, да — можно через сегменты. В lsFusion это делается легко, вот так:
CLASS Segment 'Сегмент';
name 'Наименование' = DATA ISTRING[50] (Segment);
threshold 'Порог' = DATA NUMERIC[14,2] (Segment);
discount 'Скидка' = DATA NUMERIC[8,2] (Segment);
segment (Customer c) =
GROUP LAST Segment s
ORDER threshold(s), s
WHERE soldSum(c) >= threshold(s) MATERIALIZED;
price (Customer c, Product p, Stock st, DATE d) =
price(p, st, d) * (100 (-) discount(segment(c))) / 100;
soldSum по покупателю — это по какой-то формуле рассчитанный объем продаж. При этом сегменты в такой реализации будут хранится в таблице покупателей и автоматически пересчитываться при изменении суммы продаж.
gennayo
Всё-таки, при таком подходе мне пока видится, что для более-менее сложных систем будет очень большая зависимость от архитектуры, заложенной при проектировании, в некоторой мере в ущерб гибкости. Поэтому было бы интересно увидеть статью именно о проектировании архитектуры достаточно сложной системы, с подробным обоснованием выбранных решений.
AntonLn Автор
Как раз наоборот. Наша практика показала, что это очень гибкая схема при необходимости изменений. Например, свойство price для 4х ключей объявляется как-то, затем используется в тысячи мест. А когда нужно изменить расчет, то он просто меняется, а все остальное продолжает работать как и раньше. При этом на нативном уровнем, все продолжает компилироваться в SQL.
Собственно мы и пишем статьи о том, какие «шаблоны» мы используем при проектировании сложной системы. Она и состоит из множества таких архитектурных решений. В статье есть ссылка на сложную систему — ERP-систему для розницы.
gennayo
Если есть уже готовое описание архитектуры и функциональная модель этой сложной ERP-системы для розницы, с интересом бы ознакомился.
AntonLn Автор
К сожалению, какого-то визуального оформления архитектуры нет. Но там в основе лежат приемы, которые были ранее описаны в статьях про подбор, регистры, иерархии и вот эта.