image

В различных приложениях регулярно возникает задача по поддержке логики изменения во времени некоторого атрибута объекта относительно некоторого субъекта (или субъектов). Например, это может быть изменение розничной цены товара в магазинах или показателей KPI для сотрудников.

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

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

Введение


Для более простого изложения и понимания статьи в качестве атрибута возьмем цену, в качестве объекта — товар, а субъектом будет склад. При этом минимальным возможным интервалом задания атрибута будет дата. Таким образом, пользователь сможет определять, какая будет на конкретную дату цена для любого товара и склада.

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

Описанная схема имеет следующие преимущества:

  • Атомарность. Каждое изменение оформлено как отдельный документ. Таким образом эти документы можно временно сохранять, но не проводить. При ошибочном вводе легко откатить все изменение.
  • Прозрачность. Легко определить, кто и когда сделал изменение, а также указать причину, внеся ее в комментарий к документу.

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

Реализация


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

Объявление групп складов
CLASS Group 'Группа складов';
name 'Имя' = DATA ISTRING[50] (Group);

parent = DATA Group (Group);
nameParent 'Родительская группа' (Group g) = name(parent(g));

level 'Уровень' (Group child, Group parent) =
   RECURSION 1l IF child IS Group AND parent = child
        STEP 2l IF parent = parent($parent) MATERIALIZED;

FORM group 'Группа складов'
   OBJECTS g = Group PANEL
   PROPERTIES(g) name, nameParent
  
   EDIT Group OBJECT g
;

FORM groups 'Группы складов'
   OBJECTS g = Group
   PROPERTIES(g) READONLY name, nameParent
   PROPERTIES(g) NEWSESSION NEWEDITDELETE
  
   LIST Group OBJECT g
;

NAVIGATOR {
   NEW groups;
}

Пример иерархии групп
image


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

Объявление складов
CLASS Stock 'Склад';
name 'Имя' = DATA ISTRING[50] (Stock);

group 'Группа' = DATA Group (Stock);
nameGroup 'Группа' (Stock st) = name(group(st));

FORM stock 'Склад'
   OBJECTS s = Stock PANEL
   PROPERTIES(s) name, nameGroup
  
   EDIT Stock OBJECT s
;

FORM stocks 'Склады'
   OBJECTS s = Stock
   PROPERTIES(s) READONLY name, nameGroup
   PROPERTIES(s) NEWSESSION NEWEDITDELETE
  
   LIST Stock OBJECT s
;

NAVIGATOR {
   NEW stocks;
}


Пример складов
image


И, наконец, объявим логику товаров:

Объявление товаров
CLASS Product 'Товар';
name 'Имя' = DATA ISTRING[50] (Product);

FORM product 'Товар'
   OBJECTS p = Product PANEL
   PROPERTIES(p) name
  
   EDIT Product OBJECT p
;

FORM products 'Товары'
   OBJECTS p = Product
   PROPERTIES(p) READONLY name
   PROPERTIES(p) NEWSESSION NEWEDITDELETE
  
   LIST Product OBJECT p
;

NAVIGATOR {
   NEW products;
}

Пример товаров
image


Перейдем непосредственно к созданию логики прайс-листов. Сначала зададим сам класс Прайс-лист, а также период его действия:
CLASS PriceList 'Прайс-лист';
fromDate 'Дата с' = DATA DATE (PriceList);
toDate 'Дата по' = DATA DATE (PriceList);
Считаем, что если Дата по не задана, то прайс-лист действует бесконечно.
Добавим событие, которое будет при создании прайс-листа автоматически проставлять текущей дату, с которой он начнет действовать.
WHEN LOCAL SET(PriceList p IS PriceList) DO
   fromDate(p) <- currentDate();
Ключевое слово LOCAL означает, что событие будет срабатывать не в момент применения сохранения в базу данных, а непосредственно в момент изменения.

Затем добавим пользователя, который его создал, и время создания:
createdTime 'Время создания' = DATA DATETIME (PriceList);
createdUser = DATA User (PriceList);
nameCreatedUser 'Пользователь' (PriceList p) = name(createdUser(p));
Теперь создадим событие, которое будет автоматически их заполнять:
WHEN SET(PriceList p IS PriceList) DO {
   createdTime(p) <- currentDateTime();
   createdUser(p) <- currentUser();
}
Это событие, в отличие от предыдущего, будет срабатывать только в тот момент, когда будет нажата кнопка Сохранить. То есть во время транзакции сохранения в базу данных.

Дальше создадим строки прайс-листа, в которых будут заданы товары и цены:
CLASS PriceListDetail 'Строка прайс-листа';
priceList = DATA PriceList (PriceListDetail) NONULL DELETE;

product = DATA Product (PriceListDetail);
nameProduct 'Товар' (PriceListDetail d) = name(product(d));

price 'Цена' = DATA NUMERIC[10,2] (PriceListDetail);
Атрибут NONULL указывает на то, что свойство priceList всегда должно быть задано, а DELETE — что при обнулении значения свойства (например, при удалении прайс-листа), нужно автоматически удалить соответствующую строку.

Для последующего использования создадим свойства, которые будут определять период действия строк прайс-листов:
fromDate 'Дата с' (PriceListDetail d) = fromDate(priceList(d));
toDate 'Дата по' (PriceListDetail d) = toDate(priceList(d));
Теперь сделаем привязку прайс-листа к складам, для которых он будет действовать. Вначале добавим первичное свойство, которое будет истинно, если в прайс-лист включена вся группа складов целиком:
dataIn 'Вкл' = DATA BOOLEAN (PriceList, Group);
Посчитаем «включенность» группы с учетом выбранных родителей (как было описано в статье про иерархии):
in 'Вкл (итого)' (PriceList p, Group child) =
   GROUP LAST dataIn(p, Group parent) ORDER DESC level(child, parent) WHERE dataIn(p, parent);
Добавим первичное свойство, при помощи которого можно указать, что прайс-лист действует на определенный склад:
dataIn 'Вкл' = DATA BOOLEAN (PriceList, Stock);
Посчитаем итоговое свойство, которое будет определять, что прайс-лист изменяет цены на соответствующем складе с учетом групп:
in 'Вкл' (PriceList p, Stock s) = dataIn(p, s) OR in(p, group(s));
Создадим свойство, которое будет показывать названия всех выбранных групп и складов прайс-листа, для более удобного просмотра пользователем списка прайс-листов:
stocks 'Склады' (PriceList p) = CONCAT ' / ',
   GROUP CONCAT name(Group g) IF dataIn(p, g), ',' ORDER g,
   GROUP CONCAT name(Stock s) IF dataIn(p, s), ',' ORDER s
   CHARWIDTH 30;
Заключительным этапом в описании доменной логики будет непосредственно расчет действующей цены товара на складе. Для этого создается свойство, которое находит последнюю по дате строку прайс-листа с нужным товаров, складом и периодом действия:
priceListDetail (Product p, Stock s, DATE dt) =
   GROUP LAST PriceListDetail d
         ORDER fromDate(d), d
         WHERE product(d) = p AND in(priceList(d), s) AND
               fromDate(d) <= dt AND NOT toDate(d) < dt;
В логике расчета этого свойства возможны различные вариации. Можно изменить как фильтр попадания строк (например, добавив в WHERE условие на то, что прайс-лист проведен), так и порядок. Следует отметить, что в порядок выбора вторым параметром добавлен сам объект, а точнее его внутренний идентификатор. Это нужно, чтобы значение цены всегда определялось однозначным образом.

На основе полученной строки прайс-листа определим значение цены и ее период действия:
price 'Цена' (Product p, Stock s, DATE dt) = price(priceListDetail(p, s, dt));
fromDate 'Дата с'  (Product p, Stock s, DATE dt) = fromDate(priceListDetail(p, s, dt));
toDate 'Дата по'  (Product p, Stock s, DATE dt) = toDate(priceListDetail(p, s, dt));
Они будут в дальнейшем использоваться в таблицах пользовательского интерфейса.

Дальше перейдем к построению пользовательского интерфейса. Сначала нарисуем форму по редактированию прайс-листа. Создаем форму и добавляем туда “шапку” документа:
FORM priceList 'Прайс-лист'
   OBJECTS p = PriceList PANEL
   PROPERTIES(p) fromDate, toDate
  
   EDIT PriceList OBJECT p
;
Добавляем на форму строки прайс-листа:
EXTEND FORM priceList
   OBJECTS d = PriceListDetail
   PROPERTIES(d) nameProduct, price
   PROPERTIES(d) NEWDELETE
   FILTERS priceList(d) = p
;
Дальше добавляем дерево, в которой будут как группы, так и склады:
EXTEND FORM priceList
   TREE stocks g = Group PARENT parent, s = Stock
   PROPERTIES READONLY name(g), name(s)
   PROPERTIES dataIn(p, g), in(p, g)
   PROPERTIES dataIn(p, s), in(p, s)
   FILTERS group(s) = g
;
Свойства для групп и складов в дерево добавляются одновременно. Платформа будет, в зависимости от объекта, показывать то или иное свойство в порядке их добавления на форму.

Настраиваем дизайн формы, чтобы товары и склады рисовались в отдельные вкладки:
DESIGN priceList {
   OBJECTS {
       NEW pane {
           fill = 1;
           type = TABBED;
           MOVE BOX(d) { caption = 'Товары'; }
           MOVE BOX(TREE stocks) { caption = 'Склады'; }
       }
   }
}
Форма редактирования будет выглядеть следующим образом:

image

image

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

Для реализации первой вкладки добавим на форму список прайс-листов с строками для быстрого предпросмотра:
FORM managePrices 'Управление ценами'
   OBJECTS p = PriceList
   PROPERTIES(p) READONLY fromDate, toDate, stocks, createdTime, nameCreatedUser
   PROPERTIES(p) NEWSESSION NEWEDITDELETE
  
   OBJECTS d = PriceListDetail
   PROPERTIES(d) READONLY nameProduct, price
   FILTERS priceList(d) = p
;
Для второй вкладки добавим сначала дату, на которую показывать цены, дерево групп складов, а также сами склады:
EXTEND FORM managePrices
   OBJECTS dt = DATE PANEL
   PROPERTIES VALUE(dt)
  
   TREE groups g = Group PARENT parent
   PROPERTIES READONLY name(g)
  
   OBJECTS s = Stock
   PROPERTIES(s) READONLY name, nameGroup
   FILTERS level(group(s), g)
;
В списке складов будут показываться все склады, которые являются потомками выбранной вверху группы.

Дальше добавляем на форму список товаров, для которых есть действующие цены по складу на выбранную дату:
EXTEND FORM managePrices
   OBJECTS pr = Product
   PROPERTIES READONLY name(pr), price(pr, s, dt), fromDate(pr, s, dt), toDate(pr, s, dt)
   FILTERS price(pr, s, dt)
;
В колонки добавляются как сама цена, так и период действия. Можно также добавить номер прайс-листа — тогда эта таблица будет напоминать логику аннотаций в системах контроля версий.

Для того, чтобы пользователь понимал откуда взялась такая цена, добавим вниз список строк прайс-листов с подходящими товарами и складами:
EXTEND FORM managePrices
   OBJECTS prd = PriceListDetail
   PROPERTIES READONLY BACKGROUND (priceListDetail(pr, s, dt) = prd)
              fromDate(prd), toDate(prd), 'Склады' = stocks(priceList(prd)), price(prd)
   FILTERS product(prd) = pr AND in(priceList(prd), s)
;
При помощи атрибута BACKGROUND подсвечиваем строку, которая определила показанную в таблице цену.

Также, для удобства пользователя, добавим возможность сразу из этой истории открывать форму редактирования соответствующего прайс-листа в новой сессии:
edit (PriceListDetail d) + { edit(priceList(d)); }
EXTEND FORM managePrices
   PROPERTIES(prd) NEWSESSION EDIT
;
Чтобы достичь этого, нужно указать действие, которое будет выполняться при попытке редактирования строки путем имплементации встроенного действия edit. Затем на форму стандартным образом добавляется стандартная кнопка по редактированию объекта через вызов диалога.

И, наконец, формируем итоговый дизайн формы:
DESIGN managePrices {
   OBJECTS {
       NEW pane {
           fill = 1;
           type = TABBED;
           NEW priceLists {
               caption = 'Прайс-листы';
               MOVE BOX(p);
               MOVE BOX(d);
           }
           NEW prices {
               caption = 'Цены';
               fill = 1;
               type = SPLITH;
               NEW leftPane {
                   MOVE BOX(dt);
                   MOVE BOX(TREE groups);
                   MOVE BOX(s);
               }
               NEW rightPane {
                   fill = 3;
                   type = SPLITV;
                   MOVE BOX(pr) { fill = 3; }
                   MOVE BOX(prd);
               }
           }
       }
   }
}
Здесь сначала добавляется контейнер pane, который состоит из двух вкладок: priceLists и prices. В первую из них просто добавляются список прайс-листов и строки. Во второй создаются две панели: leftPane и rightPane. Левая панель содержит дату и склады, а правая — товары и историю изменения цен.

Результат


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

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

image

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

image

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

image

Полученное свойство с ценой товара по складу на дату можно в дальнейшем использовать в различных событиях или других формах. Например, можно сделать автоматическое проставление цены в заказе на основе этой логики определения цены:
WHEN LOCAL CHANGED(sku(UserOrderDetail d)) OR CHANGED(stock(d)) OR CHANGED(dateTime(d)) DO 
    price(d) <- price(sku(d), stock(d), dateTime(d));
Приятным бонусом в такой логике будет то, что при добавлении нового склада в группу, на него будут автоматически распространятся цены из уже созданных прайс-листов. Тоже самое будет происходить и при изменении группы для склада.

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

Заключение


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

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

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

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

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

Исходный код
REQUIRE Authentication, Time;

CLASS Group 'Группа складов';
name 'Имя' = DATA ISTRING[50] (Group);

parent = DATA Group (Group);
nameParent 'Родительская группа' (Group g) = name(parent(g));

level 'Уровень' (Group child, Group parent) = 
    RECURSION 1l IF child IS Group AND parent = child
         STEP 2l IF parent = parent($parent) MATERIALIZED;

FORM group 'Группа складов'
    OBJECTS g = Group PANEL
    PROPERTIES(g) name, nameParent
    
    EDIT Group OBJECT g
;

FORM groups 'Группы складов'
    OBJECTS g = Group
    PROPERTIES(g) READONLY name, nameParent
    PROPERTIES(g) NEWSESSION NEWEDITDELETE
    
    LIST Group OBJECT g
;

NAVIGATOR {
    NEW groups;
}

CLASS Stock 'Склад';
name 'Имя' = DATA ISTRING[50] (Stock);

group 'Группа' = DATA Group (Stock);
nameGroup 'Группа' (Stock st) = name(group(st));

FORM stock 'Склад'
    OBJECTS s = Stock PANEL
    PROPERTIES(s) name, nameGroup
    
    EDIT Stock OBJECT s
;

FORM stocks 'Склады'
    OBJECTS s = Stock
    PROPERTIES(s) READONLY name, nameGroup
    PROPERTIES(s) NEWSESSION NEWEDITDELETE
    
    LIST Stock OBJECT s
;

NAVIGATOR {
    NEW stocks;
}

CLASS Product 'Товар';
name 'Имя' = DATA ISTRING[50] (Product);

FORM product 'Товар'
    OBJECTS p = Product PANEL
    PROPERTIES(p) name
    
    EDIT Product OBJECT p
;

FORM products 'Товары'
    OBJECTS p = Product
    PROPERTIES(p) READONLY name
    PROPERTIES(p) NEWSESSION NEWEDITDELETE
    
    LIST Product OBJECT p
;

NAVIGATOR {
    NEW products;
}

CLASS PriceList 'Прайс-лист';
fromDate 'Дата с' = DATA DATE (PriceList);
toDate 'Дата по' = DATA DATE (PriceList);

createdTime 'Время создания' = DATA DATETIME (PriceList);
createdUser = DATA User (PriceList);
nameCreatedUser 'Пользователь' (PriceList p) = name(createdUser(p));

WHEN LOCAL SET(PriceList p IS PriceList) DO
    fromDate(p) <- currentDate();

WHEN SET(PriceList p IS PriceList) DO {
    createdTime(p) <- currentDateTime();
    createdUser(p) <- currentUser();
}

CLASS PriceListDetail 'Строка прайс-листа';
priceList = DATA PriceList (PriceListDetail) NONULL DELETE;

product = DATA Product (PriceListDetail);
nameProduct 'Товар' (PriceListDetail d) = name(product(d));

price 'Цена' = DATA NUMERIC[10,2] (PriceListDetail);

fromDate 'Дата с' (PriceListDetail d) = fromDate(priceList(d));
toDate 'Дата по' (PriceListDetail d) = toDate(priceList(d));

dataIn 'Вкл' = DATA BOOLEAN (PriceList, Group);

in 'Вкл (итого)' (PriceList p, Group child) = 
    GROUP LAST dataIn(p, Group parent) ORDER DESC level(child, parent) WHERE dataIn(p, parent);

dataIn 'Вкл' = DATA BOOLEAN (PriceList, Stock);
in 'Вкл' (PriceList p, Stock s) = dataIn(p, s) OR in(p, group(s));

stocks 'Склады' (PriceList p) = CONCAT ' / ',
    GROUP CONCAT name(Group g) IF dataIn(p, g), ',' ORDER g,
    GROUP CONCAT name(Stock s) IF dataIn(p, s), ',' ORDER s
    CHARWIDTH 30;

priceListDetail (Product p, Stock s, DATE dt) = 
    GROUP LAST PriceListDetail d 
          ORDER fromDate(d), d 
          WHERE product(d) = p AND in(priceList(d), s) AND 
                fromDate(d) <= dt AND NOT toDate(d) < dt;
                
price 'Цена' (Product p, Stock s, DATE dt) = price(priceListDetail(p, s, dt));
fromDate 'Дата с'  (Product p, Stock s, DATE dt) = fromDate(priceListDetail(p, s, dt));
toDate 'Дата по'  (Product p, Stock s, DATE dt) = toDate(priceListDetail(p, s, dt));

FORM priceList 'Прайс-лист'
    OBJECTS p = PriceList PANEL
    PROPERTIES(p) fromDate, toDate
    
    EDIT PriceList OBJECT p
;

EXTEND FORM priceList
    OBJECTS d = PriceListDetail
    PROPERTIES(d) nameProduct, price 
    PROPERTIES(d) NEWDELETE
    FILTERS priceList(d) = p
;

EXTEND FORM priceList
    TREE stocks g = Group PARENT parent, s = Stock
    PROPERTIES READONLY name(g), name(s)
    PROPERTIES dataIn(p, g), in(p, g) 
    PROPERTIES dataIn(p, s), in(p, s) 
    FILTERS group(s) = g
;

DESIGN priceList {
    OBJECTS {
        NEW pane {
            fill = 1;
            type = TABBED;
            MOVE BOX(d) { caption = 'Товары'; }
            MOVE BOX(TREE stocks) { caption = 'Склады'; }
        }
    }
}

FORM managePrices 'Управление ценами'
    OBJECTS p = PriceList
    PROPERTIES(p) READONLY fromDate, toDate, stocks, createdTime, nameCreatedUser
    PROPERTIES(p) NEWSESSION NEWEDITDELETE 
    
    OBJECTS d = PriceListDetail
    PROPERTIES(d) READONLY nameProduct, price
    FILTERS priceList(d) = p
;

EXTEND FORM managePrices
    OBJECTS dt = DATE PANEL
    PROPERTIES VALUE(dt)
    
    TREE groups g = Group PARENT parent
    PROPERTIES READONLY name(g)
    
    OBJECTS s = Stock
    PROPERTIES(s) READONLY name, nameGroup
    FILTERS level(group(s), g)
;

EXTEND FORM managePrices
    OBJECTS pr = Product
    PROPERTIES READONLY name(pr), price(pr, s, dt), fromDate(pr, s, dt), toDate(pr, s, dt)
    FILTERS price(pr, s, dt)
;

EXTEND FORM managePrices
    OBJECTS prd = PriceListDetail
    PROPERTIES READONLY BACKGROUND (priceListDetail(pr, s, dt) = prd) 
               fromDate(prd), toDate(prd), 'Склады' = stocks(priceList(prd)), price(prd)
    FILTERS product(prd) = pr AND in(priceList(prd), s)
;

edit (PriceListDetail d) + { edit(priceList(d)); }
EXTEND FORM managePrices
    PROPERTIES(prd) NEWSESSION EDIT 
;

DESIGN managePrices {
    OBJECTS {
        NEW pane {
            fill = 1;
            type = TABBED;
            NEW priceLists {
                caption = 'Прайс-листы';
                MOVE BOX(p);
                MOVE BOX(d);
            }
            NEW prices {
                caption = 'Цены';
                fill = 1;
                type = SPLITH;
                NEW leftPane {
                    MOVE BOX(dt);
                    MOVE BOX(TREE groups);
                    MOVE BOX(s);
                }
                NEW rightPane {
                    fill = 3;
                    type = SPLITV;
                    MOVE BOX(pr) { fill = 3; }
                    MOVE BOX(prd);
                }
            }
        }
    }
}

NAVIGATOR {
    NEW managePrices;
}

Комментарии (18)


  1. gennayo
    25.09.2019 12:45

    А если к этой схеме добавить покупателя, для каждого из которых прайс рассчитывается «на лету» на основании некоего набора базовых прайсов, то что и как будет в БД храниться?


    1. AntonLn Автор
      25.09.2019 12:49

      Не очень понял, куда именно нужно добавлять покупателя. Для каждого покупателя будут свои прайсы? Если так, то просто добавляется в шапку покупатель, а затем в расчете priceListDetail (Product p, Stock s, DATE dt) добавляется еще один параметр, который затем используется в WHERE. И получаются разные цены для разных покупателей.


      1. gennayo
        25.09.2019 14:14

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


        1. AntonLn Автор
          25.09.2019 14:31

          Можно сделать вот как: есть базовые прайсы как в статье. Дальше, на основе их уже высчитаны текущие цены: см. свойство price (Product, Stock, DATE). Дальше можно его умножать, или делить, например, на скидку покупателю (или любое другое выражение). То есть:
          price (Customer c, Product p, Stock st, DATE d) = price(p, st, d) * (100 — discount( c )) / 100;
          И дальше использовать эту цену в заказах или где-то еще.


          1. gennayo
            25.09.2019 15:01

            Интересно, какое будет быстродействие получения данных в таком случае, если, например, нужно по запросу отдать по API прайсы из 5000 позиций на 50 покупателей, если прайс будет вычисляться по сложной формуле, например, от объема продаж за текущий месяц с учётом возвратов и ещё парочки подобных критериев.


            1. AntonLn Автор
              25.09.2019 15:12

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

              Правда запросы будут зависеть от расставленных MATERIALIZED. Как я писал в статье: можно денормализовать через MATERIALIZED кортеж из PriceListDetail, Stock в одну таблицу и построить по ней индекс по полям (product, stock, fromDate). Тогда для каждого product и stock PostgreSQL будет находить цену по индексу за логарифм.

              Если учитывать другие параметры (объемы продаж и прочее), то тоже можно сделать MATERIALIZED на них, и все будет хранится в таблицах покупателей (или других с небольшим количеством записей) и автоматически обновляться. Тогда к ним будет идти простой JOIN. Ну или не делать все эти MATERIALIZED, тогда СУБД будет их высчитывать в момент запроса (пойдут такие запросы).

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


              1. gennayo
                25.09.2019 15:24

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


                1. AntonLn Автор
                  25.09.2019 15:41

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

                  Когда добавляется новый MATERIALIZED на старте автоматически создается поле и рассчитывается по нужной формуле. Обычно это идет достаточно быстро, так как этот расчет идет не на сервере приложений, а одним запросом на SQL сервере и делается достаточно быстро (мы на практике добавляем поля как в таблицы с сотнями миллионов записей, так и где расчет зависит от таких таблиц). И это все занимало максимум один час. Добавление пустой колонки в PostgreSQL вообще мгновенная операция.

                  В любом случае, тут 2 варианта: либо считать в момент запроса, либо при записи инкрементно обновлять значения. Третьего не дано. И в lsFusion это все делается прозрачно, без изменения остальной логики приложения.


                  1. gennayo
                    25.09.2019 16:28

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


                    1. AntonLn Автор
                      26.09.2019 09:56

                      К сожалению, пока ничего нет в это части. Мы пока акцентируем внимание на бизнес-приложениях и информационных системах, но для таких вещей есть из коробки JSON API, возможность прямо из lsFusion кода делать HTTP вызовы, и возможность спускаться на уровень ниже и писать на Java.


                      1. gennayo
                        26.09.2019 10:14

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


                1. prolis
                  25.09.2019 16:17

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


                  1. gennayo
                    25.09.2019 16:37

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


                  1. AntonLn Автор
                    26.09.2019 09:54

                    Кстати, да — можно через сегменты. В 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 по покупателю — это по какой-то формуле рассчитанный объем продаж. При этом сегменты в такой реализации будут хранится в таблице покупателей и автоматически пересчитываться при изменении суммы продаж.


                    1. gennayo
                      26.09.2019 10:10

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


                      1. AntonLn Автор
                        26.09.2019 11:02

                        Как раз наоборот. Наша практика показала, что это очень гибкая схема при необходимости изменений. Например, свойство price для 4х ключей объявляется как-то, затем используется в тысячи мест. А когда нужно изменить расчет, то он просто меняется, а все остальное продолжает работать как и раньше. При этом на нативном уровнем, все продолжает компилироваться в SQL.

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


                        Собственно мы и пишем статьи о том, какие «шаблоны» мы используем при проектировании сложной системы. Она и состоит из множества таких архитектурных решений. В статье есть ссылка на сложную систему — ERP-систему для розницы.


                        1. gennayo
                          26.09.2019 12:01

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


                          1. AntonLn Автор
                            26.09.2019 14:25

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