Pull to refresh
114.57
Haulmont
Создаем современные корпоративные системы

Домашняя бухгалтерия на платформе CUBA

Reading time 16 min
Views 36K


Цель этой статьи — рассказать о возможностях платформы CUBA на примере создания небольшого полезного приложения.
CUBA предназначена для быстрой разработки бизнес-приложений на Java, мы уже писали о ней несколько статей на Хабре.

Обычно на платформе строятся либо реальные, но слишком большие и закрытые информационные системы, либо приложения в стиле “Hello World” или искусственные примеры типа “Библиотеки” на нашем сайте. Поэтому некоторое время назад я и решил попробовать убить сразу двух зайцев — написать для себя полезное приложение и выложить его в общий доступ как пример использования нашей платформы, благо предметная область простая и всем понятная.


Что получилось в итоге


Если коротко, то приложение решает две основные задачи:
  1. На любой момент времени показывает текущий баланс по всем видам денежных средств: наличные, карты, депозиты, долги и т.д.
  2. Формирует отчет по категориям доходов и расходов, позволяющий узнать, на что тратились или откуда поступали деньги в определенный период.

Чуть более подробно:
  • Различные виды денежных средств представляются счетами.
  • Возможны операции по приходу на счет, расходу со счета и переводу денежных средств между счетами.
  • В операции прихода или расхода можно задать категорию для уточнения, откуда пришли или на что потрачены деньги.
  • Баланс по всем счетам на текущую дату отображается постоянно и пересчитывается после совершения каждой операции.
  • Отчет по категориям доходов и расходов показывает сводку по двум произвольным периодам одновременно для быстрого визуального сравнения. Любую категорию можно исключить из сравнения. По каждой строке отчета можно “провалиться” в операции, чтобы посмотреть, из чего она состоит.
  • Система представляет собой три веб-приложения, развернутых на одном Tomcat:
    1. Middleware
    2. Полнофункциональный UI на CUBA
    3. Responsive UI на Backbone.js + Bootstrap для удобства ввода операций на мобильных устройствах.

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

Немного скриншотов


Основной UI: список операций

Основной UI: отчет по категориям доходов/расходов

Responsive UI: список операций

Responsive UI: текущий баланс


Как запустить


Исходный код проекта здесь: github.com/knstvk/akkount (КК — это мои инициалы, ничего лучше в голову не пришло).
Сама платформа не является свободной, однако пяти одновременных подключений в бесплатной лицензии более чем достаточно для домашнего применения, так что если кто-то захочет использовать — пожалуйста.

Для работы требуется только JDK 7+ и установленная переменная среды JAVA_HOME. Для сборки откройте командную строку в корне проекта и запустите
gradlew setupTomcat deploy

Загрузится Gradle, который скачает интернет платформу и другие библиотеки, а затем соберет приложение в подкаталоге build/tomcat. В процессе сборки вам будет предложено принять лицензионное соглашение на платформу CUBA.
После этого нужно запустить сервер HSQL и создать БД в подкаталоге data проекта:
gradlew startDb
gradlew createDb

Для запуска Томката можно воспользоваться командой Gradle
gradlew start
либо скриптами startup.* в подкаталоге build/tomcat/bin.
Основной веб-интерфейс приложения доступен на localhost:8080/app, responsive UI — на localhost:8080/app-portal. Пользователь — admin, пароль — admin.

База данных изначально пустая, для ее наполнения тестовыми данными есть генератор. Он доступен через меню Администрирование -> Консоль JMX -> app-core.akkount -> app-core.akkount:type=SampleDataGenerator. Здесь есть метод generateSampleData(), который принимает на вход целое число — количество дней назад от текущей даты, за которые нужно создать операции. Введите, например, 200, и нажмите Запустить. Подождите, пока операция отработает, затем выйдите (значок в правом верхнем углу) и снова войдите в систему. Вы увидите примерно то же самое, что и на моих скриншотах.

Как заглянуть внутрь


Для изучения и доработки приложения рекомендую скачать и установить CUBA Studio, IntelliJ IDEA и плагин CUBA для нее.

Далее я не буду подробно останавливаться на том, как и что делается в Студии. Там и так все визуально, есть контекстная помощь, есть видеоматериалы и документация по платформе. Поясню единственный нюанс с использованием базы данных HSQL: Студия при открытии проекта, использующего HSQL DB, запускает свой собственный сервер на порту 9001 и хранит базы данных в каталоге ~/.haulmont/studio/hsqldb. Это означает, что если вы запускали сервер HSQL отдельно от Студии командами Gradle, вам нужно остановить его. Файлы базы данных, если необходимо, можно просто перенести из data/akk в ~/.haulmont/studio/hsqldb/akk.

Вообще, приложение можно запустить и на более серьезной БД — PostgreSQL, Microsoft SQL Server или Oracle. Для этого в Студии достаточно выбрать нужный тип БД в Project properties, затем выполнить Entities -> Generate DB Scripts, потом в главном меню Run -> Create database.

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

Модель данных




Классы сущностей располагаются в модуле global, который доступен как среднему слою, так и веб-клиентам.

В основном это обычные сущности JPA, соответствующим образом проаннотированные и зарегистрированные в persistence.xml. Большинство из них имеет также специфическую для CUBA аннотацию @NamePattern, которая задает “имя экземпляра” — как отображать в UI конкретный экземпляр сущности, что-то вроде toString(). Если такая аннотация не задана, в качестве имени экземпляра используется как раз toString(), возвращающий имя класса и идентификатор объекта. Еще одна специфическая аннотация — @Listeners, задает классы листенеров создания/изменения объектов. Листенеры сущностей ниже будут рассмотрены подробно.

Кроме JPA-сущностей в проекте имеется неперсистентная сущность CategoryAmount. Экземпляры неперсистентных сущностей не хранятся в БД, а используются только для передачи данных между слоями приложения и отображения стандартными UI компонентами. В данном случае такая сущность используется для формирования отчета по категориям: на среднем слое извлекаются данные, создаются и заполняются экземпляры CategoryAmount, а в веб-клиенте эти экземпляры кладутся в источники данных (datasources), и отображаются в таблицах. Стандартные компоненты Table ничего не знают о происхождении сущностей — для них это просто объекты, известные в метаданных приложения. А чтобы включить неперсистентную сущность в метаданные, необходимо добавить ее классу аннотацию @MetaClass, атрибутам — аннотацию @MetaProperty, и зарегистрировать класс в файле metadata.xml. Персистентные сущности, разумеется, тоже описаны в метаданных — для этого загрузчик метаданных на старте приложения разбирает также и файл persistence.xml.

Рядом с сущностями располагаются и классы перечислений (enums), например OperationType. Перечисления, использующиеся в модели данных в атрибутах сущностей, не совсем обычные: они реализуют интерфейс EnumClass и имеют поле id. Таким образом от Java-значения отделяется значение, хранимое в БД. Это дает возможность обеспечивать совместимость с данными в production DB при произвольном рефакторинге кода приложения.

В файлах messages.properties и messages_ru.properties пакета сущностей находятся локализованные названия сущностей и их атрибутов. Эти названия используются в UI, если визуальные компоненты не переопределяют их на своем уровне. Файлы сообщений — это обычные наборы ключ-значение в кодировке UTF-8. Поиск сообщения для некоторой локали аналогичен правилам PropertyResourceBundle — сначала ключ ищется в файлах с суффиксом, соответствующим локали, если не найден — в файлах без суффикса.

Рассмотрим сущности модели.
  • Currency — валюта. Имеет уникальный код и произвольное название. Уникальность кода валюты поддерживается уникальным индексом, который Студия включает в скрипты создания БД, если аннотация @Column содержит свойство unique = true. Платформа содержит обработчик исключения, выбрасываемого при нарушении уникальности в БД. Этот обработчик выдает стандартное сообщение пользователю. Обработчик можно подменить у себя в проекте.
  • Account — счет. Имеет уникальное имя и произвольное описание. Содержит также ссылку на валюту и отдельное поле кода валюты. Это поле — пример денормализации для улучшения производительности. Так как в списках счета как правило отображаются вместе с кодом валюты, имеет смысл избавиться от join в запросах к БД, добавив код валюты в сам счет. Обновлять код валюты в счете при смене валюты счета (хоть на практике это происходит крайне редко) мы заставим листенер сущности — об этом чуть позже. Счет содержит также атрибут active — признак того, что он доступен для использования в новых операциях, и атрибут includeInTotal — признак того, что остаток по этому счету нужно включать в совокупный баланс.
  • Category — категория доходов или расходов. Имеет уникальное имя и произвольное описание. Атрибут catType — тип категории, определяется перечислением CategoryType. Как уже объяснялось выше, в поле класса и в БД хранится значение, определяемое идентификатором перечисления (в данном случае строка “E” или “I”), а геттер и сеттер, а значит, и весь прикладной код, работают со значениями CategoryType.INCOME и CategoryType.EXPENSE.
  • Operation — операция. Атрибуты операции: тип (перечисление OperationType), дата, счета расхода и прихода (acc1, acc2) и соответствующие суммы (amount1, amount2), категория и комментарии.
  • Balance — баланс по счету на некоторую дату. Вообще, для домашней бухгалтерии вполне можно было бы обойтись без этой сущности и рассчитывать баланс всегда динамически “с начала времен”: просто сложить весь приход и отнять весь расход по счету. Но я для интереса решил усложнить реализацию на случай большого количества операций — баланс по счету на начало каждого месяца хранится в экземплярах Balance, при записи каждой операции балансы на начало следующего месяца (и позже, если есть) пересчитываются. Зато для расчета баланса на текущую дату нужно только взять баланс на начало месяца и посчитать оборот по операциям текущего месяца. Такой подход не вызовет проблем с производительностью со временем.
  • UserData — key-value хранилище некоторых данных, связанных с пользователем. Например, последний использованный счет, параметры отчета по категориям. То есть здесь хранится то, что необходимо “вспоминать” при повторных действиях пользователя. Возможные ключи заданы константами в классе UserDataKeys.


Entity Listeners




Если вы работали с JPA, то наверняка использовали и листенеры сущностей. Это удобный механизм для выполнения каких-либо действий в момент сохранения изменений сущностей в БД. Самое важное то, что все изменения, внесенные листенерами, производятся в той же транзакции — аналогично триггерам БД. Поэтому на листенерах удобно организовывать логику поддержания консистентности модели данных.

Листенеры сущностей в CUBA несколько отличаются по реализации от JPA. Класс листенера должен реализовывать один или несколько специальных интерфейсов (BeforeInsertEntityListener, BeforeUpdateEntityListener и др.). Регистрируются листенеры на классе сущности в аннотации @Listeners перечислением имен классов в массиве строк. Использовать литералы классов листенеров напрямую в классе сущности нельзя, так как сущность — глобальный объект, доступный и среднему слою, и клиентам, а листенер — объект только среднего слоя, недоступный клиентам. Листенеры живут только на среднем слое потому, что им нужен доступ к EntityManager и другим средствам работы с БД.

В данном приложении листенеры сущностей выполняют две функции: во-первых, обновляют денормализованные поля, а во-вторых, пересчитывают балансы по счетам на начало месяцев.
Первая задача тривиальна: листенер AccountEntityListener в методах onBeforeInsert(), onBeforeUpdate() обновляет значение кода валюты. Для этого ему достаточно обратиться к связанному экземпляру Currency.
Вторая задача по сути является одной из основных в бизнес-логике приложения. Занимается этим OperationEntityListener в методах onBeforeInsert(), onBeforeUpdate(), onBeforeDelete(). Кроме пересчета баланса, этот листенер также запоминает в объектах UserData последние использованные счета.

Следует отметить, что в Before-листенерах нет никаких ограничений на использование EntityManager, загрузку и модификацию экземпляров любых сущностей. Например, в addOperation() с помощью Query загружаются и модифицируются экземпляры Balance. Они будут сохранены в БД одновременно с операцией в одной транзакции.

Иногда в листенере требуется получить “предыдущее” состояние имеющегося сейчас в персистентном контексте объекта, то есть то состояние, которое сейчас находится в БД. Например, в данном случае в onBeforeUpdate() нам нужно сначала вычесть из баланса предыдущее значение суммы операции, а потом прибавить новое значение. Для этого в методе getOldOperation() стартует новая транзакция с помощью persistence.createTransaction(), в ее контексте получается другой экземпляр EntityManager, и через него загружается из БД предыдущее состояние операции с тем же идентификатором. Затем новая транзакция завершается, никак не влияя на текущую, в которой работает наш листенер.

Компоненты среднего слоя




Основную работу по загрузке данных на клиентский уровень и сохранению внесенных пользователем изменений в БД выполняет стандартный DataService, реализованный в платформе. Через него работают источники данных визуальных компонентов. В нашем приложении этого недостаточно, поэтому созданы несколько специфических сервисов.

Во-первых, это UserDataService, который позволяет работать с key-value хранилищем UserData, предоставляя типизированный интерфейс для чтения и записи идентификаторов сущностей. Интерфейс сервиса находится в модуле global, потому что он должен быть доступен клиентскому уровню. Реализация сервиса находится в модуле core в классе UserDataServiceBean. Она делегирует вызовы бину UserDataWorker, в котором и сосредоточен код, выполняющий полезную работу. Сделано так потому, что эта функциональность требуется также и в OperationEntityListener, то есть “изнутри” среднего слоя. Сервис же образует “границу middleware” и предназначен только для вызова из клиентских блоков. Вызывать его изнутри компонентов среднего слоя не следует, так как это приводит к повторному срабатыванию интерцептора, проверяющего аутентификацию и обрабатывающего специальным образом исключения. Да и просто в целях наведения порядка стоит отделять сервисы, вызываемые снаружи middleware, от остальных бинов, вызываемых изнутри. Хотя бы потому, что при вызове снаружи транзакция всегда отсутствует, а при вызове из кода middleware транзакция уже может быть открыта.

Следующий сервис — BalanceService. Он позволяет получать значение остатка на счете на произвольную дату. Так как данная функциональность требуется и клиентам в UI, и на среднем слое (генератору тестовых данных), то она также вынесена в отдельный бин BalanceWorker.

И последний сервис — ReportService. Он извлекает данные для отчета по категориям, и возвращает их в виде списка экземпляров неперсистентной сущности CategoryAmount.

На среднем слое реализован также бин SampleDataGenerator, который предназначен для генерации тестовых данных. Для функциональности такого рода обычно не требуется сложный UI — достаточно обеспечить вызов с передачей простых параметров, иногда нужно отобразить какое-то состояние в виде набора атрибутов. Кроме того, работает с этим только администратор, а не пользователи системы. В таком случае удобно дать бину JMX-интерфейс и вызывать его методы из встроенной в веб-клиент JMX-консоли, либо подключившись любым внешним инструментом JMX. В нашем случае у бина есть интерфейс SampleDataGeneratorMBean, и он зарегистрирован в spring.xml модуля core.

Обратите внимание, что метод generateSampleData() бина аннотирован как @Authenticated. Это означает, что при вызове данного метода будет выполнен специальный системный логин и в потоке выполнения будет присутствовать пользовательская сессия. Она требуется в данном случае потому, что метод создает и изменяет через EntityManager сущности, которые при сохранении требуют установки их атрибутов createdBy, updatedBy — кто изменял данные экземпляры. С другой стороны, метод removeAllData(), также вызываемый через JMX-интерфейс, не требует аутентификации потому, что он удаляет данные с помощью SQL-запросов через QueryRunner и нигде не обращается к пользовательской сессии.

Вообще, обязательная проверка наличия пользовательской сессии производится только на входе в средний слой со стороны клиентского уровня — в интерцепторе сервисов. Проверять или не проверять наличие сессии и права пользователя на уровне middleware — решает разработчик приложения, но в некоторых случаях наличие сессии обязательно из-за необходимости проставлять имя пользователя в атрибутах аудита сущностей. Кроме того, права пользователей всегда проверяются в DataWorker — бине, которому DataService делегирует выполнение CRUD-операций с сущностями.

Главное окно приложения


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

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


Сделано это следующим образом:
  • От платформенного FoldersPane унаследован класс LeftPanel, переопределены методы init() и refreshFolders(), в которых вызывается метод createBalancePanel(). В нем создается новый контейнер, заполняется данными, полученными из BalanceService, и помещается вверху родительского контейнера.
  • Чтобы LeftPanel использовался вместо стандартного FoldersPane, от платформенного AppWindow унаследован класс AkkAppWindow и переопределен метод createFoldersPane().
  • Чтобы в свою очередь AkkAppWindow использовался вместо стандартного AppWindow, переопределен метод createAppWindow() класса App. Кроме того, здесь определен метод доступа к новой панели getLeftPanel() — он вызывается из экранов для обновления баланса после коммита или удаления операций.


Браузер операций


Описатель экрана расположен в файле operation-browse.xml. Здесь все стандартно, за исключением использования классов-форматтеров для представления даты и сумм в таблице операций.

Для отображения даты применяется платформенный DateFormatter, которому передается формат по ключу из пакета локализованных сообщений. Таким образом строка формата может быть разной для разных языков — для русского дата разделена точками, а для английского — символами /.
Для того, чтобы суммы отображались без дробной части, а 0 не отображался совсем, в проекте создан класс DecimalFormatter — он и используется в колонках сумм.

Редактор операции


Здесь интереснее: операция может быть одного из трех типов (приход, расход, перевод), и экран редактирования должен выглядеть для них по-разному.




Первые два экрана на первый взгляд кажутся одинаковыми, но на самом деле это не так: визуальные компоненты работают с разными атрибутами сущности Operation — расход с acc1 и amount1, доход с acc2 и amount2. Эту изменчивость можно было бы реализовать полностью в коде контроллера, но я решил сделать это более декларативно — разнеся отличающиеся части экрана в отдельные фреймы.

Фреймов три — по количеству типов операции. Все они располагаются в том же пакете, что и сам экран редактирования операции. Чаще всего фреймы подключаются статически — используя компонент iframe в XML-дескрипторе экрана. Нам это не подходит, так как нужно выбирать нужный фрейм в зависимости от типа операции. Поэтому в XML-дескрипторе экрана operation-edit.xml определен только контейнер для фрейма — компонент groupBox с идентификатором frameContainer, а собственно создание и вставка фрейма в экран выполняется в контроллере OperationEdit:
    @Inject
    private GroupBoxLayout frameContainer;

    private OperationFrame operationFrame;

    @Override
    public void init(Map<String, Object> params) {
    ...
            String frameId = operation.getOpType().name().toLowerCase() + "-frame";
            operationFrame = openFrame(frameContainer, frameId, params);

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

В методе init() контроллера OperationEdit есть еще один интересный момент — регистрируется листенер, срабатывающий после коммита операции:
    @Override
    public void init(Map<String, Object> params) {
        ...
        getDsContext().addListener(new DsContext.CommitListenerAdapter() {
            @Override
            public void afterCommit(CommitContext context, Set<Entity> result) {
                LeftPanel leftPanel = App.getLeftPanel();
                if (leftPanel != null)
                        leftPanel.refreshBalance();
            }
        });
    }

Этот листенер обновляет содержимое левой панели, отображающей текущий баланс.

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

Рассмотрим expense-frame.xml. В нем объявлен компонент textField с идентификатором amountField. В контроллере ExpenseFrame используется бин AmountCalculator, в котором инкапсулирована логика расчета суммы:
    @Inject
    private TextField amountField;

    @Inject
    private AmountCalculator amountCalculator;

    @Override
    public void postInit(Operation item) {
            amountCalculator.initAmount(amountField, item.getAmount1());
        …
    }

    @Override
    public void postValidate(ValidationErrors errors) {
            BigDecimal value = amountCalculator.calculateAmount(amountField, errors);
        …
    }

Этот же бин, определенный на слое Web Client, используется и в двух других контроллерах фреймов. Метод initAmount() бина устанавливает в текстовом поле текущую сумму, отформатированную по типу данных BigDecimal. Просто указать datatype = decimal для компонента нельзя, так как в этом случае в него можно будет ввести только число, а нам нужно иметь возможность вводить и арифметические выражения. Метод calculateAmount() проверяет выражение на корректность с помощью regexp, а затем выполняет его как выражение на Groovy через интерфейс Scripting. Результатом будет число, которое и возвращается контроллеру экрана для простановки в операцию.

Отчет по категориям




Этот интерактивный отчет реализуется экраном categories-report.xml. Интересен он в первую очередь тем, что содержит два кастомных источника данных типа CategoryAmountDatasource. Класс источника данных указан в атрибуте datasourceClass элемента collectionDatasource. Для этих источников данных указан и JPQL-оператор, однако он не используется и присутствует только потому, что Студия автоматически генерирует текст запроса, если его не указать. На самом деле источник данных CategoryAmountDatasource переопределяет метод loadData() и вместо загрузки данных через DataService по JPQL-запросу, обращается к сервису ReportService, передавая ему нужные параметры:
public class CategoryAmountDatasource extends CollectionDatasourceImpl<CategoryAmount, UUID> {

    private ReportService service = AppBeans.get(ReportService.NAME);

    @Override
    protected void loadData(Map<String, Object> params) {
    ...
            Date fromDate = (Date) params.get("from");
            Date toDate = (Date) params.get("to");
        ...
            List<CategoryAmount> list = service.getTurnoverByCategories(fromDate, toDate, categoryType, currency.getCode(), ids);
            for (CategoryAmount categoryAmount : list) {
                    data.put(categoryAmount.getId(), categoryAmount);
            }
        ...
    }

Параметры устанавливаются контроллером экрана в методе refresh() источника данных — см. методы refreshDs1(), refreshDs2() класса CategoriesReport. Сервис возвращает список экземпляров неперсистентной сущности CategoryAmount, и источник данных сохраняет их в своей коллекции data. Таким образом таблицы, связанные с этими источниками данных, отображают экземпляры CategoryAmount как любые другие сущности, загруженные из БД обычным способом.

Интересно устроена функциональность кнопки Исключить, позволяющая убрать из рассмотрения выбранную категорию.

В дескрипторе categories-report.xml объявлены две такие кнопки — для левой и правой таблицы. Каждая из кнопок связана с действием excludeCategory своей таблицы. Однако для таблиц в XML-дескрипторе не объявлено никаких действий. Как же это работает? Дело в том, что действия для таблиц в данном случае добавляются в методе init() контроллера экрана: см. метод initExcludedCategories(). В этом методе также “вспоминается” список ранее исключенных категорий, запомненных с помощью сервиса UserDataService.

Действие типа ExcludeCategoryAction при срабатывании вызывает метод excludeCategory(), который через ComponentsFactory создает контейнер и надпись с кнопкой-ссылкой, соответствующие исключаемой категории, и помещает новый контейнер внутрь объявленного заранее в дескрипторе контейнера excludedBox. Для каждой кнопки создается листенер, при срабатывании которого весь контейнер, в котором находится кнопка вместе с надписью, удаляется из родительского контейнера. Кроме того, обновляются источники данных, переформировывая списки категорий.

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

Благодарности


Некоторые идеи я почерпнул из замечательного сервиса zenmoney.ru, которым пользовался некоторое время. Все open-source библиотеки и фреймворки, входящие в состав платформы, перечислены в окне Help -> About -> Credits.

Продолжение следует


В следующей статье об этом же приложении я планирую рассказать об устройстве блока responsive UI, который написан на Backbone.js + Bootstrap и взимодействует со средним слоем через REST API. Кроме того, постараюсь немного изменить тему основного UI и дополнить его новым UI-компонентом, чтобы проиллюстрировать возможности кастомизации интерфейса в проектах.
Tags:
Hubs:
+23
Comments 17
Comments Comments 17

Articles

Information

Website
www.haulmont.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия
Representative
Haulmont