Gerenciamento de parâmetros em aplicativos de negócios semelhantes a um sistema de controle de versão

imagem

Em várias aplicações, surge regularmente a tarefa de apoiar a lógica da mudança no tempo de algum atributo de um objeto em relação a um determinado assunto (ou assuntos). Por exemplo, isso pode ser uma alteração no preço de varejo de mercadorias nas lojas ou indicadores de KPI para funcionários.

Neste artigo, mostrarei que lógica e interfaces de domínio podem ser criadas para resolver esse problema. Farei imediatamente uma reserva de que isso envolverá a influência gerencial do usuário no atributo, e não o reflexo da mudança histórica.

A implementação será apresentada com base na plataforma aberta e gratuita da lsFusion , mas um esquema semelhante pode ser aplicado ao usar qualquer outra tecnologia.

1. Introdução


Para uma apresentação e compreensão mais simples do artigo, consideramos o preço como um atributo, o produto como o objeto e o armazém será o assunto. Nesse caso, o intervalo mínimo possível para definir o atributo será a data. Assim, o usuário poderá determinar qual será o preço para uma data específica para qualquer produto e armazém.

O esquema de entrada do usuário para alterações de preço será semelhante ao usado nos sistemas de controle de versão clássicos. Qualquer alteração, do ponto de vista da lógica do domínio, será uma única confirmação , com base na qual o status para uma determinada data será calculado. Em muitas áreas, tais confirmações são chamadas de documentos ou transações. Nesse caso, com esse commit, queremos dizer a chamada lista de preços. Cada lista de preços especificará as mercadorias e os depósitos incluídos nela, bem como o período de validade.

O esquema descrito possui as seguintes vantagens:

  • Atomicidade . Cada alteração é emitida como um documento separado. Portanto, esses documentos podem ser salvos temporariamente, mas não lançados. Com uma entrada incorreta, é fácil reverter toda a alteração.
  • Transparência É fácil determinar quem fez a alteração e quando, além de indicar o motivo, fazendo um comentário no documento.

A principal diferença do sistema de controle de versão é que confirmações explícitas são independentes uma da outra. Assim, é possível excluir todos os commits de maneira relativamente indolor a qualquer momento. Além disso, cada confirmação desse tipo pode ser configurada para terminar quando deixar de funcionar, o que obviamente não está no sistema de controle de versão.

Implementação


Iniciamos a definição de lógica de domínio com armazéns. Vamos complicar um pouco a solução combinando armazéns em uma hierarquia de um grupo de profundidade dinâmica. De acordo com o princípio, isso é descrito no artigo correspondente, portanto, fornecerei um código que declara grupos e cria formulários para editá-los:

Anúncio do Grupo de Armazém
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 NEW , EDIT , DELETE

LIST Group OBJECT g
;

NAVIGATOR {
NEW groups;
}

Exemplo de hierarquia de grupo
imagem


Em seguida, declare armazéns que podem ser vinculados a qualquer um dos grupos:

Anúncio do armazém
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 NEW , EDIT , DELETE

LIST Stock OBJECT s
;

NAVIGATOR {
NEW stocks;
}


Exemplo de armazém
imagem


E, finalmente, declare a lógica dos bens:

Anúncio do produto
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 NEW , EDIT , DELETE

LIST Product OBJECT p
;

NAVIGATOR {
NEW products;
}

Exemplo de produto
imagem


Prosseguimos diretamente na criação da lógica das listas de preços. Primeiro, definimos a própria classe de lista de preços , bem como seu período de validade:
CLASS PriceList '-' ;
fromDate ' ' = DATA DATE (PriceList);
toDate ' ' = DATA DATE (PriceList);
Acreditamos que, se nenhuma data foi definida, a lista de preços é interminável.
Adicionamos um evento que, ao criar a lista de preços, anulará automaticamente a data atual a partir da qual começará a operar.
WHEN LOCAL SET (PriceList p IS PriceList) DO
fromDate(p) <- currentDate();
A palavra-chave LOCAL significa que o evento não será acionado quando o salvamento for aplicado ao banco de dados, mas imediatamente quando a alteração for feita.

Em seguida, adicione o usuário que o criou e o horário da criação:
createdTime ' ' = DATA DATETIME (PriceList);
createdUser = DATA User (PriceList);
nameCreatedUser '' (PriceList p) = name(createdUser(p));
Agora crie um evento que os preencherá automaticamente:
WHEN SET (PriceList p IS PriceList) DO {
createdTime(p) <- currentDateTime();
createdUser(p) <- currentUser();
}
Este evento, diferente do anterior, será acionado apenas quando o botão Salvar for clicado. Ou seja, durante uma transação salva no banco de dados.

Em seguida, crie as linhas da lista de preços nas quais os bens e os preços serão definidos:
CLASS PriceListDetail ' -' ;
priceList = DATA PriceList (PriceListDetail) NONULL DELETE ;

product = DATA Product (PriceListDetail);
nameProduct '' (PriceListDetail d) = name(product(d));

price '' = DATA NUMERIC [ 10 , 2 ] (PriceListDetail);
O atributo NONULL indica que a propriedade priceList sempre deve ser definida e DELETE indica que quando o valor da propriedade é zerado (por exemplo, ao excluir a lista de preços), a linha correspondente deve ser excluída automaticamente.

Para uso futuro, criaremos propriedades que determinarão o período de validade das linhas da lista de preços:
fromDate ' ' (PriceListDetail d) = fromDate(priceList(d));
toDate ' ' (PriceListDetail d) = toDate(priceList(d));
Agora, vincularemos a lista de preços aos armazéns para os quais operará. Primeiro, adicione a propriedade principal, o que será verdadeiro se todo o grupo de armazéns estiver incluído na lista de preços:
dataIn '' = DATA BOOLEAN (PriceList, Group);
Calculamos a “inclusão” do grupo levando em consideração os pais selecionados (conforme descrito no artigo sobre hierarquias):
in ' ()' (PriceList p, Group child) =
GROUP LAST dataIn(p, Group parent) ORDER DESC level(child, parent) WHERE dataIn(p, parent);
Adicione a propriedade principal, com a qual você pode especificar que a lista de preços atue em um armazém específico:
dataIn '' = DATA BOOLEAN (PriceList, Stock);
Calculamos a propriedade final, que determinará que a lista de preços altere os preços no armazém correspondente, levando em consideração os grupos:
in '' (PriceList p, Stock s) = dataIn(p, s) OR in(p, group(s));
Crie uma propriedade que mostre os nomes de todos os grupos e depósitos selecionados da lista de preços, para que um usuário mais conveniente exiba a lista de listas de preços:
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 ;
A etapa final na descrição da lógica do domínio calculará diretamente o preço atual das mercadorias no armazém. Para fazer isso, crie uma propriedade que encontre a última linha de data da lista de preços com as mercadorias, o armazém e o período de validade desejados:
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;
Na lógica do cálculo dessa propriedade, várias variações são possíveis. Você pode alterar o filtro para acessar as linhas (por exemplo, adicionar uma condição em WHERE que a lista de preços seja lançada) e o pedido. Deve-se notar que o próprio objeto, ou melhor, seu identificador interno, foi adicionado à ordem de seleção pelo segundo parâmetro. Isso é necessário para que o valor do preço seja sempre determinado de uma maneira única.

Com base na linha de lista de preços recebida, determinamos o valor do preço e seu período de validade:
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));
Eles serão mais usados ​​em tabelas de interface do usuário.

Em seguida, passamos à criação da interface do usuário. Primeiro, desenhamos um formulário para editar a lista de preços. Crie um formulário e adicione o "cabeçalho" do documento lá:
FORM priceList '-'
OBJECTS p = PriceList PANEL
PROPERTIES (p) fromDate, toDate

EDIT PriceList OBJECT p
;
Adicione a linha da lista de preços ao formulário:
EXTEND FORM priceList
OBJECTS d = PriceListDetail
PROPERTIES (d) nameProduct, price
PROPERTIES (d) NEW , DELETE
FILTERS priceList(d) = p
;
Em seguida, adicione uma árvore na qual haverá grupos e armazéns:
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
;
Propriedades para grupos e armazéns são adicionadas à árvore ao mesmo tempo. A plataforma, dependendo do objeto, mostrará essa ou aquela propriedade na ordem em que forem adicionadas ao formulário.

Personalizamos o design do formulário para que mercadorias e armazéns sejam desenhados em guias separadas:
DESIGN priceList {
OBJECTS {
NEW pane {
fill = 1 ;
type = TABBED ;
MOVE BOX (d) { caption = '' ; }
MOVE BOX ( TREE stocks) { caption = '' ; }
}
}
}
O formulário de edição ficará assim:

imagem

imagem

Resta criar a forma básica de gerenciamento de preços. Ele será composto por duas guias. O primeiro mostrará uma lista de todas as listas de preços (semelhante à lista de confirmações). A segunda guia exibirá os preços atuais de um armazém específico para a data selecionada.

Para implementar a primeira guia, adicione ao formulário uma lista de listas de preços com linhas para uma visualização rápida:
FORM managePrices ' '
OBJECTS p = PriceList
PROPERTIES (p) READONLY fromDate, toDate, stocks, createdTime, nameCreatedUser
PROPERTIES (p) NEWSESSION NEW , EDIT , DELETE

OBJECTS d = PriceListDetail
PROPERTIES (d) READONLY nameProduct, price
FILTERS priceList(d) = p
;
Para a segunda guia, primeiro adicionamos a data em que os preços são exibidos, a árvore dos grupos de depósitos e os próprios armazéns:
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)
;
A lista de armazéns mostrará todos os armazéns descendentes do grupo selecionado na parte superior.

Em seguida, adicione ao formulário uma lista de mercadorias para as quais existem preços válidos para o depósito na data selecionada:
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)
;
O preço em si e o período de validade são adicionados às colunas. Você também pode adicionar o número da lista de preços - essa tabela será semelhante à lógica das anotações nos sistemas de controle de versão.

Para que o usuário entenda de onde veio esse preço, adicionamos a lista de linhas da lista de preços com produtos e armazéns adequados:
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)
;
Usando o atributo BACKGROUND, destaque a linha que determinou o preço mostrado na tabela.

Além disso, para conveniência do usuário, adicionaremos a capacidade de abrir o formulário de edição da lista de preços correspondente em uma nova sessão imediatamente a partir desta história:
edit (PriceListDetail d) + { edit(priceList(d)); }
EXTEND FORM managePrices
PROPERTIES (prd) NEWSESSION EDIT
;
Para conseguir isso, você precisa especificar a ação que será executada ao tentar editar uma linha implementando a ação de edição interna. Em seguida, um botão padrão para editar um objeto por meio de uma chamada de diálogo é adicionado ao formulário da maneira padrão.

E, finalmente, formamos o design final do formulário:
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);
}
}
}
}
}
Aqui, o contêiner do painel é adicionado primeiro, que consiste em duas guias: priceLists e prices . O primeiro deles apenas adiciona uma lista de listas de preços e linhas. No segundo, dois painéis são criados: leftPane e rightPane . O painel esquerdo contém a data e os armazéns, e o painel direito contém os bens e o histórico de preços.

Resultado


Considere as principais opções para usar a lógica resultante.

Suponha que tenhamos duas listas de preços separadas para diferentes grupos de mercadorias. Em seguida, dependendo do armazém selecionado, na guia preços, apenas os produtos das listas de preços correspondentes serão exibidos:

imagem

Agora crie uma nova lista de preços com um período de validade limitado, uma lista simplificada de armazéns e um novo preço. Na segunda guia, se selecionarmos uma data no intervalo da nova lista de preços, obteremos um novo preço. Assim que o período de validade expirar, o preço antigo retornará novamente do preço original:

imagem

Usando o mesmo mecanismo, você pode "cancelar" a ação de preços específicos a partir de uma determinada data. Por exemplo, se você inserir um novo preço sem especificar um preço, o preço será redefinido e as mercadorias desaparecerão do filtro. Nesse caso, ao excluir o documento digitado, tudo volta ao estado antigo:

imagem

A propriedade resultante com o preço das mercadorias pelo armazém na data pode ser usada ainda mais em vários eventos ou outras formas. Por exemplo, você pode fazer preços automáticos em um pedido com base nesta lógica de preços:
WHEN LOCAL CHANGED (sku(UserOrderDetail d)) OR CHANGED (stock(d)) OR CHANGED (dateTime(d)) DO
price(d) <- price(sku(d), stock(d), dateTime(d));
Um bom bônus nessa lógica é que, quando você adiciona um novo armazém ao grupo, os preços das listas de preços já criadas se aplicam automaticamente a ele. O mesmo acontecerá quando você mudar o grupo para o armazém.

Se desejar, você pode tornar a coluna com o preço na guia com preços atuais editável e adicionar um botão que criará um novo commit para os preços alterados.

Conclusão


Na solução no nível da plataforma, nem os livros de referência, nem os documentos com seqüências de caracteres, nem registros, nem relatórios e outras abstrações desnecessárias são usados. Tudo é feito exclusivamente nos conceitos de classes e propriedades. Observe que essa lógica bastante complexa foi implementada em aproximadamente 150 linhas de código significativas no lsFusion. Implementá-lo na mesma formulação em outras plataformas (por exemplo, 1C) é uma tarefa muito mais difícil.

O esquema descrito acima é amplamente utilizado na solução ERP baseada em lsFusion. Utilizando-o, com várias modificações, são suportadas listas de preços de fornecedores, preços de varejo de gerenciamento, ações e muitos outros parâmetros de gerenciamento.

O modelo pode ser complicado adicionando várias entidades ao documento (por exemplo, um fornecedor pode ser adicionado ao armazém), além de definir vários atributos em um documento ao mesmo tempo. Em particular, você pode adicionar a entidade Tipo de preço e, na linha do documento, definir o preço da tupla da linha e o tipo de preço correspondente. Na lógica descrita acima, você só precisa adicionar alguns parâmetros adicionais a algumas propriedades.

Com a ajuda de várias linhas de código adicionais, é possível desnormalizar todos os registros de alterações em uma tabela na qual criar o índice correspondente. Em seguida, a seleção de qualquer valor para qualquer data será feita em um horário logarítmico. Essa otimização é necessária quando existem várias centenas de milhões de registros nesta tabela.

Você pode experimentar o exemplo criado online na página correspondente do site (seção Plataforma). Aqui está o código fonte completo que você precisa colar no campo desejado:

Código fonte
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 NEW , EDIT , DELETE

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 NEW , EDIT , DELETE

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 NEW , EDIT , DELETE

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) NEW , DELETE
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 NEW , EDIT , DELETE

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;
}

Source: https://habr.com/ru/post/pt468553/


All Articles