logo

[ELMA3] Массовый импорт данных

Массовый импорт данных в системе ELMA используется для автоматического переноса информации из внешнего источника данных (например, из какой-либо БД или файла Excel) в основную БД системы ELMA.

Импорт данных в систему ELMA производится пакетами. Пакет – это количество записей, которое будет импортировано за одну итерацию. Размер пакета настраивается в провайдере внешнего источника данных.

Схема процесса массового импорта данных выглядит следующим образом:

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

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

  • Метод bool CanUse(Type type) выполняется перед всем процессом импорта один раз и показывает, будет ли применен обработчик.
  • Метод void BeforeMapping(DataTable data, BulkDataImportHandlerData handlerData) выполняется на каждой итерации процесса импорта после получения данных в виде плоской таблицы, но перед маппингом данных и предназначен для просмотра и/или внесения изменений в плоскую таблицу перед маппингом данных. Содержит в себе плоскую таблицу и объект данных маппинга, который будет передан из метода в метод.
  • Метод void AfterImportPage(IEnumerable<MapInfo> mapInfos, BulkDataImportHandlerData handlerData) выполняется после загрузки результатов маппинга после каждой итерации процесса импорта. Пример применения этого метода – пересчет прав доступа при импорте документов в ранее созданные папки. Для пересчета требуется отдельно пересчитать права доступа на каждый документ, поэтому данный метод наиболее применим к пакету объектов. Содержит в себе древовидную структуру данных и объект данных маппинга, который будет передан из метода в метод.
  • Метод void AfterImport(BulkDataImportHandlerData handlerData) выполняется перед завершением всего процесса импорта. Данный метод может быть применен, например, для пересчета прав доступа в CRM (данная точка расширения уже реализована в системе ELMA). Содержит в себе объект данных импорта. Пример точки расширения для пересчета прав доступа к объектам CRM представлен ниже:
using System;
using System.Collections.Generic;
using System.Data;

using EleWise.ELMA.Common.BulkDataImport.Handlers;
using EleWise.ELMA.Common.BulkDataImport.Mapping.Models;
using EleWise.ELMA.ComponentModel;
using EleWise.ELMA.CRM.Models;
using EleWise.ELMA.Runtime.NH;

using NHibernate;

namespace EleWise.ELMA.CRM.ExtensionPoints
{
    /// <summary>
    /// Обработчик импорта для пересчета прав доступа к объектам модуля "Работа с клиентами"
    /// </summary>
    [Component(Order = 100)]
    public class CRMBulkDataImportHandler : IBulkDataImportHandler
    {
        private ISessionProvider sessionProvider;

        /// <summary>
        /// Ctor
        /// </summary>
        /// <param name="sessionProvider"><see cref="ISessionProvider"/></param>
        public CRMBulkDataImportHandler(ISessionProvider sessionProvider)
        {
            this.sessionProvider = sessionProvider;
        }

        /// <summary>
        /// Обработчик срабатывает если произведен импорт по маппингу КОнтрагентов или Возможностей
        /// </summary>
        /// <param name="type"></param>
        /// <returns></returns>
        public virtual bool CanUse(Type type)
        {
            return typeof(IContractor).IsAssignableFrom(type) || typeof(ILead).IsAssignableFrom(type);
        }

        /// <summary>
        /// Пересчитываем права доступа ко всем объектам CRM
        /// </summary>
        /// <param name="handlerData">Данные импорта</param>
        public virtual void AfterImport(BulkDataImportHandlerData handlerData)
        {
            var session = sessionProvider.GetSession("");
            session.GetNamedQuery("CheckInheritedPermission")
                .SetTimeout(0)
                .ExecuteUpdate(false);
        }

        // реализация остальных методов интерфейса
Внимание!
  1. Возможность массового импорта данных реализована только для коммерческих редакций системы ELMA.
  2. Перед началом импорта необходимо создать резервную копию базы данных ELMA.
  3. Массовый импорт данных может потреблять большое количество ресурсов как сервера ELMA, так и СУБД, поэтому его рекомендуется производить в нерабочее время.
  4. Крайне не рекомендуется одновременно запускать более одного импорта, т.к. это может привести к конфликтам, связанным с блокировками БД.
  5. Каждый импортируемый объект должен иметь хотя бы одно ключевое поле, в противном случае в качестве ключевого поля будет принято поле уникального идентификатора Uid (при его наличии), при отсутствии поля Uid все записи будут добавляться в качестве новых экземпляров объекта. Ключевых полей может быть несколько – это именно те поля, по которым происходит поиск существующих в ELMA объектов.
  6. Во избежание снижения производительности при выполнении массового импорта настоятельно рекомендуется в качестве ключевых полей использовать индексированные поля.
  7. В случае если в БД ELMA существует запись об объекте, ключевое поле которого совпадает со значением из импортируемой таблицы, новый объект не будет создан, будет обновлен существующий объект.
  8. Следует помнить, что перед импортом данных в БД Oracle, необходимо убедиться, что ключевые поля ограничены по длине, иначе – поиск объектов производиться не будет, объект будет создан как новый, что может привести к дублированию данных. Чтобы проверить ключевое поле системного объекта, необходимо открыть СУБД, найти в ней требуемые таблицу и поле, проверить его тип данных на ограничение по длине. Если это пользовательский объект, данную проверку можно провести в Дизайнере ELMA на вкладке Объекты в карточке требуемого объекта на вкладке Свойства.
  9. В случае если в правилах маппинга не заданы правила для свойства, то при импорте свойство будет проигнорировано. Если в правилах маппинга задано правило для свойства, но не задана колонка, из которой должен производится импорт, будет осуществлен поиск колонки с таким же названием, как и у свойства.
  10. Для анализа корректности работы правил маппинга следует подключить логирование в файле log4net.config, расположенном в папке ../<Общая папка с файлами системы ELMA>/Web/Config:
...
<logger name="BulkDataImport" additivity="false">
    <level value="OFF"/> // изменить значение "OFF" на требуемый уровень логирования (например, "INFO" или "ERROR")
    <appender-ref ref="BulkDataImportLog" />
  </logger>
...

Файлы лога расположены в папке ../<Общая папка с файлами системы ELMA>/Web/logs/BulkDataImport.

Рассмотрим пример импорта списка физических лиц из внешнего источника в БД системы ELMA, а именно в объект Физическое лицо.

Пример провайдера внешнего источника данных для базы данных Firebird представлен ниже:

[Component]
    public class FirebirdBulkDataImportDataSource : DBDataSourceBase
    {

        internal static string TableName = " ContractorData"; // ContractorData – имя таблицы в БД, из которой будет производиться импорт данных
        internal static long CountConst = 10;

        private bool canGetCount = false;

        public FirebirdBulkDataImportDataSource(bool canGetCount)
        {
            this.canGetCount = canGetCount;
        }

        public override int PackageSize
        {
            get
            {
                return 5; // размер запрашиваемого из внешнего источника данных пакета для обработки
            }
        }

        public static string PathDataBase // полный путь к файлу БД
        {
            get
            {
                return Path.Combine(IOExtensions.GetTempPath(), "WorkDir", "commonTest.fdb");
            }
        }

        public static string ConnectionString
        {
            get
            {
                return string.Format("Data Source=localhost;Initial Catalog={0};User ID=sysdba;Password=masterkey;Dialect=3;ServerType=0", PathDataBase); // localhost – адрес СУБД, из которой будет производиться импорт данных
            }
        }

        protected override string CommandText()
        {
            return string.Format("select first {0} skip {1} LastName, FirstName, Patronymic, INN, Phones_String, AddressListCount, Address_Region, Address_Town, Address_Street, Address_Home from {2} order by Id", // LastName, FirstName, Patronymic, INN, Phones_String, AddressListCount, Address_Region, Address_Town, Address_Street, Address_Home – имена свойств объекта, которые будут импортированы
                PackageSize,
                StartIndex, TableName);
        }

        protected override string CountCommandText()
        {
            return canGetCount
                ? string.Format("select Count(*) from {0}", TableName)
                : string.Empty;
        }

        protected override IDbConnection CreateConnection()
        {
            return new FbConnection(ConnectionString);
        }

    }

Пример использования системного провайдера внешнего источника данных для файла Excel представлен ниже:

using EleWise.ELMA.Common.BulkDataImport.DataSources;
…
var dataSource = new ExcelDataSourceBase(excelFilePath);
…

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

Ограничения, накладываемые стандартным провайдером Excel, описаны ниже:

  • провайдер блокирует изменения файла во время всей работы импорта (с начала проведения импорта и до его завершения файл нельзя будет ни удалить/ни переименовать/ни перенести);
  • в качестве названий столбцов таблицы используется только первая строка, значения в столбцах без названия (значения в первой строке) игнорируются;
  • при импорте используется только один лист файла Excel.

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

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

В дальнейшем для данной таблицы должны быть прописаны правила маппинга для перевода данных в древовидную структуру.

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

Пример таких правил маппинга представлен ниже:

private MapBuilder<IContractorIndividual> GetMapBuilder()
        {
          var mapper = MapBuilder<IContractorIndividual>.Create(null);

             return mapper
             .Rules(rule =>
             {
               rule.Property(p => p.INN) // маппинг свойства с названием INN
               .Key() // обозначаем свойство INN ключевым 
               .Column("INNCode"); // в качестве источника данных для свойства принимаем столбец INNCode.
                                   // В случае если название свойства и название столбца в таблице совпадают, данную строку можно пропустить. 
                                   // В случае если названия полей объекта и/или полей свойства-объекта совпадают, прописывание данной строки обязательно.
               
               rule.Property(p => p.SecondName)
               .Key()
               .Action(info => info.Row["LastName"].ToString().ToUpper()/*Выполняем форматирование данных, если это требуется */);

               rule.Property(p => p.FirstName);
                		
               rule.Property(p => p.MiddleName)
               .Column("Patronymic");

               rule.Property(p => p.DocumentSeries);

               rule.Property(p => p.DocumentNumber);

               rule.PropertyObject(p => p.DocumentType) // Выполняем маппинг свойства "Тип документа", которое имеет тип данных "Объект" (справочник "Тип документа клиента")
               .Rules(documentTypeRules =>
               {
                 documentTypeRules.Property(p => p.Name) 
                 .Column("DocumentType_Name")
                 .Key(); // обозначаем свойство Name ключевым
               });

               rule.PropertyObject(p => PostalAddress, a => { return a.Row["AddressListCount"] == DBNull.Value ? 0 : Convert.ToInt32(a.Row["AddressListCount"]); })
               .Rules(ruleAddress =>
               // прописываем правила маппинга для свойств "внутреннего" объекта "Адрес"
               // обозначаем все свойства объекта ключевыми, т.к. только сочетание всех полей является уникальным
               {
                 ruleAddress.Property(p => p.Building)
                 .Key()
                 .Action(info => info.Row["Address_Home" + info.Index.Value]);

                 ruleAddress.Property(p => p.Street)
                 .Key()
                 .Action(info => info.Row["Address_Street" + info.Index.Value]);

                 ruleAddress.Property(p => p.City)
                 .Key()
                 .Action(info => info.Row["Address_Town" + info.Index.Value]);

                 ruleAddress.Property(p => p.Region)
                 .Key()
                 .Action(info => info.Row["Address_Region" + info.Index.Value]);
               });
             });
        });
Внимание!
Следует отметить, что при импорте данных валидация на обязательность полей и на регулярные выражения, а также проверка на тип импортируемых данных и на значение null не происходят. Однако работают правила, накладываемые структурой БД, например, если поле в БД имеет ограничения по длине, может возникнуть ошибка при нарушении этого ограничения во входных данных.

В результате маппинга получим следующую структурированную информацию:

Объект ContractorIndividual состоит из следующих свойств:

  • INN – строка, является ключевым полем;
  • SecondName – строка;
  • FirstName – строка;
  • MiddleName – строка;
  • DocumentSeries – строка;
  • DocumentNumber – строка;
  • DocumentType – объект, состоящий из свойства:
    • DocumentType_Name – строка, является ключевым полем;
  • PostalAddress – список объектов, состоящих из свойств:
    • Building – строка, является ключевым полем;
    • Street – строка, является ключевым полем;
    • City – строка, является ключевым полем;
    • Region – строка, является ключевым полем.

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


На этапе загрузки результатов маппинга производится перенос результатов маппинга из оперативной памяти в БД ELMA.

Импорт связанных объектов

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

При маппинге объекта User нельзя создать правило для UserSecurityProfile, так как User не содержит информации о нем.

Для этих целей разработан метод AddObject<T>, который позволяет в едином процессе создать нужные объекты.

Пример использования:

private MapBuilder<IUser> GetMapBuilder()
        {
            //Маппинг основного объекта – User
            var mapping = MapBuilder<IUser>.Create(null);
            mapping.Rules(rule =>
            {
                rule.Property(p => p.Uid).Action(info => info.Row["UserUid"] == DBNull.Value ? Guid.NewGuid() : new Guid(info.Row["UserUid"].ToString())).Key(true);
                rule.Property(p => p.Status).Action(info => info.Row["Status"] == DBNull.Value ? null : Enum.Parse(typeof(UserStatus), info.Row["Status"].ToString())); ;
                rule.Property(p => p.UserName);
                rule.Property(p => p.FirstName);
                rule.Property(p => p.LastName);
                rule.Property(p => p.EmployDate).Action(info =>
                {
                    if (info.Row["EmployDate"] != DBNull.Value)
                    {
                        return DateTime.ParseExact(info.Row["EmployDate"].ToString(), "dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture);
                    }
                    return null;
                });
                rule.Extension<IUserWorkPlace>().PropertyObject(p => p.WorkPlace).Rules(ruleWP =>
                {
                    ruleWP.Property(p => p.Uid).Column("WorkPlaceUid").Key();
                });

            });

            //Инициализация данных для маппинга связанного объекта
            string salt = EncryptionHelper.GenerateSalt();
            string password = EncryptionHelper.GetSha256Hash("", salt);
            
            //Маппинг связанного объекта UserSecurityProfile
            mapping.AddObject<IUserSecurityProfile>().Rules(rule =>
            {
                rule.Property(p => p.Uid).Action(info => info.Row["UserSecurityProfileUid"] == DBNull.Value ? Guid.NewGuid() : new Guid(info.Row["UserSecurityProfileUid"].ToString())).Key();
                rule.PropertyObject(p => p.User).Rules(userRules =>
                {
                    userRules.Property(p => p.Uid).Action(info => info.Row["UserUid"] == DBNull.Value ? Guid.NewGuid() : new Guid(info.Row["UserUid"].ToString())).Key(true);
                });
                rule.Property(p => p.Salt).Action(info =>
                {
                    return salt;
                });
                rule.Property(p => p.Password).Action(info =>
                {
                    return password;
                });
            });

            return mapping;
        }

Данный пример иллюстрирует только принцип работы, но не показывает полного цикла маппинга объекта User.

Здесь важно отметить, что маппинги основного и связанного объектов осуществляются по одному и тому же принципу. Сам метод лишь дает возможность суррогатным образом связать объекты для маппинга в едином цикле.

Сценарий для запуска импорта

Таким образом, структура блока сценариев для запуска импорта выглядит следующим образом:

public virtual void StartImportAction(Context context)
        {
            var mapBuilder = GetMapBuilder();
            var dataSource = new ExcelDataSourceBase(context.DataSource.ContentFilePath);
            // выполняем импорт по заданному источнику данных и маппингу, и получаем идентификатор процесса импорта workUid
            context.WorkUid = PublicAPI.Services.BulkDataImport.Import(dataSource, mapBuilder);
        }

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

public virtual void CheckImportProcess(Context context)
        {
               var progressInfo = PublicAPI.Services.BulkDataImport.GetImportProgressInfo(context.WorkUid.Value);
            context.ProgressInfo = new JavaScriptSerializer().Serialize(progressInfo);
           // если сервис вернул результаты маппинга для заданного идентификатора workUid – значит, процесс импорта ещё идёт
            context.Success = progressInfo == null;
        }

Информация из внешнего источника данных будет импортирована в основную БД системы ELMA, а именно в экземпляры объектов Физическое лицо (ContractorIndividual), Тип документа клиента (ClientDocumentType), Адрес (Address).

Прикрепленные файлы