Pull to refresh
46.7
lsFusion
Не очередной язык программирования

Управление параметрами в бизнес-приложениях по аналогии с системой контроля версий

Reading time13 min
Views3.7K
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;
}
Tags:
Hubs:
Total votes 24: ↑24 and ↓0+24
Comments18

Articles

Information

Website
lsfusion.org
Registered
Founded
Employees
51–100 employees
Location
Беларусь
Representative
NitroJunkie