В этой статье рассмотрим, как мигрировать исторические данные из продукта Creatio (решение от Terrasoft).
Для интеграции двух систем будем использовать протокол OData.
Настроим Creatio
Для интеграции будет использовать протокол OData
Назначение протокола OData — выполнение запросов от внешних приложений к серверу баз данных Creatio.
Приложение Creatio поддерживает протоколы OData 4 и OData 3. OData 4 предоставляет больше возможностей, чем OData 3. Основное отличие протоколов — ответ на запрос, возвращаемый сервером, имеет разный формат данных. Различия протоколов OData 3 и OData 4 описаны в официальной документации OData. При планировании интеграции с Creatio по протоколу OData необходимо использовать протокол версии 4.
Ограничения при использовании протокола OData
При использовании протокола OData необходимо учитывать следующие ограничения:
- Невозможно создать системных пользователей.
- Невозможно задать культуру возвращаемых данных. Культура определяется культурой пользователя от имени которого выполняется запрос.
- Тело ответа на запрос может содержать максимум 20 000 строк.
- Пакетный запрос может содержать максимум 100 подзапросов.
- Максимальный размер файла, который можно загрузить с помощью запроса, задается системной настройкой [ MaxFileSize ] (по умолчанию — 10 Мб).
Авторизация в Creatio
Все внешние запросы к приложению Creatio должны быть аутентифицированы.
Запросы должны выполняться под учетной записью, у которой есть права на OData.
Права можно выставить следующим образом: перейти в настройки через правую панель.
Затем пройти по пути Open System designer –> Users and administration –> Operation permissions –> Access to OData.
Код:
async function auththorize(): Promise<void> {
try {
const resp = await fetch(`${baseUrl}/ServiceModel/AuthService.svc/Login`, {
method: 'POST',
headers: {
"ForceUseSession": "true",
"Content-Type": "application/json"
},
body: JSON.stringify({
"UserName": login,
"UserPassword": password
})
});
const res = await resp.json();
if (res.Code !== 0) {
throw new Error("authorization error: " + res.Message);
}
const cookieHeader = resp.headers.get("set-cookie");
if (!cookieHeader) {
throw new Error("auth headers not found");
}
await Namespace.storage.setItem("BPMCSRF_cookie", cookieHeader);
const start = cookieHeader.indexOf("BPMCSRF");
const end = cookieHeader.indexOf(";", start);
const csrf = cookieHeader.substring(start + 8, end);
await Namespace.storage.setItem("BPMCSRF", csrf);
} catch (e) {
throw e;
}
}
Рекомендуемым способом для запросов по протоколу OData является аутентификация на основе cookies (Forms-аутентификация), которая реализована с помощью веб-сервиса AuthService.svc.
Первым запросом получаем cookie, затем добавляем в заголовки при последующих запросах
Доступ к данным в Creatio
Все доступные сущности Creatio можно получить по эндпоинту: ${baseUrl}/odata/
Запрос к конкретной таблице формирует по протоколу OData, например к таблице Account: ${baseUrl}/odata/Account?$expand=Industry($select=Name),Country($select=Name),City($select=Name)&$top=50&$skip=0 - дополнительно к ответу с помощью $expand добавляются данные из таблиц Industry, Country и City, $select ограничивает список начитываемых полей.
Код:
async function getData(schema: string): Promise<Record<string, any>[] | undefined> {
const url = `${baseUrl}/odata/${schema}`;
const data = {
headers: {
"Accept": "application/json",
"Content-Type": "application/json;odata=verbose",
"ForceUseSession": "true",
"BPMCSRF": await Namespace.storage.getItem("BPMCSRF"),
"Cookie": await Namespace.storage.getItem("BPMCSRF_cookie")
},
};
try {
const resp = await fetch(url, data as FetchRequest);
if (resp.status === 500) {
if (!Context.data.is500err) {
Context.data.is500err = true;
return;
} else {
throw new Error(resp.status + ": " + resp.statusText)
}
}
if (resp.status !== 200) {
throw new Error(resp.status + ": " + resp.statusText)
}
Context.data.is500err = false;
const res = await resp.json();
Context.data.createdCount! += res.value.length;
return res.value;
} catch (e) {
throw e;
}
}
Особенности при работе c Creatio
- Если получение данных пакетами выполняется достаточно долго, то велика вероятность, что cookie станут невалидными, и вы получите ошибку 500. Отслеживайте это и выполняйте повторно команду авторизации.
- Если таблица содержит файл, при запросе к ней в ответе будет поле с названием типа Data@odata.mediaReadLink, в котором будет ссылка на файл, который можно получить по пути ${baseUrl}/odata/${row[‘Data@odata.mediaReadLink’]} (тоже с добавлением BPMCSRF и Cookie в заголовок).
Код:
const resp = await fetch(`${baseUrl}/odata/${row["Data@odata.mediaReadLink"]}`, {
headers: {
"ForceUseSession": "true",
"BPMCSRF": await Namespace.storage.getItem("BPMCSRF"),
"Cookie": await Namespace.storage.getItem("BPMCSRF_cookie")
}
} as FetchRequest);
const file = await Context.fields.files.create(row.Name, await resp.arrayBuffer());
Пример миграции контактов и компаний с сохранением связей.
В Creatio контакт имеет ссылку на компанию AccountId. Поэтому мы сначала считаем данные о компаниях, затем уже о контактах.
Во время получения данных о компаниях в объект creatioAccountsIdsToElmaId будем сохранять сопоставления id компании в Creatio и id компании в ELMA365. Аналогично сделаем для сохранения связи между компаниями и отраслями в объекте industriesCreatioToElma.
Получение компаний и контактов идет пакетами, так как в системе ELMA365 есть ограничение на время выполнения сценария. Будем ходить по циклу Шлюз – Блок transferAccounts до тех пор, пока контекстная переменная createdEnd не станет равна true.
Листинг сценария бизнес-процесса
Код:
const baseUrl = 'http://192.168.10.210:5389';
const login = "Supervisor";
const password = "Supervisor";
let creatioAccountsIdsToElmaId: Record<string, string>;
let industriesCreatioToElma: Record<string, string>;
// TYPES..
// Размеры пакетов настраиваются эмпирическим путем
const accountBatchSize = 60;
const contactBatchSize = 70;
function initMaps() {
creatioAccountsIdsToElmaId = Context.data.creatioAccountsIdsToElmaStr ? JSON.parse(Context.data.creatioAccountsIdsToElmaStr) : {};
industriesCreatioToElma = Context.data.industriesCreatioToElmaStr ? JSON.parse(Context.data.industriesCreatioToElmaStr) : {};
}
async function transferAccounts(): Promise<void> {
const accountsSchema = `Account?$expand=Industry($select=Name),Country($select=Name),City($select=Name)&$top=${accountBatchSize}&$skip=${Context.data.createdCount}`;
try {
Context.data.iterationsCounter!++;
initMaps();
const accounts = await getData(accountsSchema);
// при 500 попробуем еще раз
if (!Context.data.is500err) {
if (accounts && accounts.length) {
await parseAccountsToElma(accounts as Account[]);
} else {
Context.data.createdEnd = true;
}
Context.data.creatioAccountsIdsToElmaStr = JSON.stringify(creatioAccountsIdsToElmaId);
Context.data.industriesCreatioToElmaStr = JSON.stringify(industriesCreatioToElma);
}
} catch (e) {
throw e;
}
}
// Миграция контактов
async function transferContacts(): Promise<void> {
const contactsSchema = `Contact?$top=${contactBatchSize}&$skip=${Context.data.createdCount}`;
try {
Context.data.iterationsCounter!++;
initMaps();
const contacts = await getData(contactsSchema);
if (!Context.data.is500err) {
if (contacts && contacts.length) {
await parseContactsToElma(contacts as Contact[]);
} else {
Context.data.createdEnd = true;
}
}
} catch (e) {
throw e;
}
}
// Конвертируем контакты из Creatio в ELMA365
async function parseContactsToElma(rows: Contact[]): Promise<void> {
try {
for (const row of rows) {
const contact = Context.fields.contact.app.create();
contact.data.__name = row.Name;
contact.data._account = [];
if (row.Facebook) {
contact.data._account.push({ login: row.Facebook } as TAccount<AccountType.Facebook>);
}
if (row.LinkedIn) {
contact.data._account.push({ login: row.LinkedIn } as TAccount<AccountType.Other>);
}
if (row.Twitter) {
contact.data._account.push({ login: row.Twitter } as TAccount<AccountType.Other>);
}
if (row.Skype) {
contact.data._account.push({ login: row.Skype } as TAccount<AccountType.Skype>);
contact.data._skype = row.Skype;
}
if (row.Email) {
contact.data._email = { email: row.Email, type: EmailType.Work };
}
contact.data._fullname = { firstname: row.Name, lastname: row.Surname, middlename: row.MiddleName };
contact.data._phone = [];
if (row.Phone) {
contact.data._phone.push({ tel: row.Phone.replace(/-|\s/g, ""), type: PhoneType.Main });
}
if (row.MobilePhone) {
contact.data._phone.push({ tel: row.MobilePhone.replace(/-|\s/g, ""), type: PhoneType.Mobile });
}
if (row.HomePhone) {
contact.data._phone.push({ tel: row.HomePhone.replace(/-|\s/g, ""), type: PhoneType.Home });
}
contact.data._position = row.JobTitle;
if (row.AccountId !== "") {
const elmaCompanyId = creatioAccountsIdsToElmaId[row.AccountId];
if (elmaCompanyId) {
const elmaCompany = await Context.fields.company.app.search().where(f => f.__id.eq(elmaCompanyId)).first();
if (elmaCompany) {
contact.data._companies = elmaCompany;
}
}
}
await contact.save();
}
} catch (e) {
throw e;
}
}
// Конвертируем компании из Creatio в ELMA365
async function parseAccountsToElma(rows: Account[]): Promise<void> {
try {
for (const row of rows) {
const company = Context.fields.company.app.create();
company.data.name = row.Name;
if (row.IndustryId !== "") {
const id = industriesCreatioToElma[row.IndustryId];
let industry: ApplicationItem<Application$_clients$otrasl$Data, Application$_clients$otrasl$Params> | undefined = undefined;
if (id) {
industry = await Context.fields.otrasl.app.search().where(f => f.__id.eq(id)).first();
}
if (!industry) {
industry = Context.fields.otrasl.app.create();
industry.data.__name = row.Industry.Name;
await industry.save();
}
company.data.otrasl = industry;
industriesCreatioToElma[row.IndustryId] = industry.id;
}
company.data._address = "";
if (row.Country) {
company.data._address += `Country: ${row.Country.Name}; `;
}
if (row.City) {
company.data._address += `City: ${row.City.Name}; `;
}
if (row.Address) {
company.data._address += `Address: ${row.Address}; `;
}
if (row.Phone) {
company.data._phone = { tel: row.Phone.replace(/-|\s/g, ""), type: PhoneType.Work };
}
company.data._website = row.Web;
await company.save();
creatioAccountsIdsToElmaId[row.Id] = company.id;
}
} catch (e) {
throw e;
}
}
// Команда авторизации в Creation
async function auththorize(): Promise<void> {
try {
const resp = await fetch(`${baseUrl}/ServiceModel/AuthService.svc/Login`, {
method: 'POST',
headers: {
"ForceUseSession": "true",
"Content-Type": "application/json"
},
body: JSON.stringify({
"UserName": login,
"UserPassword": password
})
});
const res = await resp.json();
if (res.Code !== 0) {
throw new Error("authorization error: " + res.Message);
}
const cookieHeader = resp.headers.get("set-cookie");
if (!cookieHeader) {
throw new Error("auth headers not found");
}
// Сохраняем куки, чтоб использовать их в след.запросах
await Namespace.storage.setItem("BPMCSRF_cookie", cookieHeader);
const start = cookieHeader.indexOf("BPMCSRF");
const end = cookieHeader.indexOf(";", start);
const csrf = cookieHeader.substring(start + 8, end);
await Namespace.storage.setItem("BPMCSRF", csrf);
} catch (e) {
throw e;
}
}
// Универсальный метод, чтоб получать данные из Creatio
// настройки берутся из схемы
async function getData(schema: string): Promise<Record<string, any>[] | undefined> {
const url = `${baseUrl}/odata/${schema}`;
const data = {
headers: {
"Accept": "application/json",
"Content-Type": "application/json;odata=verbose",
"ForceUseSession": "true",
"BPMCSRF": await Namespace.storage.getItem("BPMCSRF"),
"Cookie": await Namespace.storage.getItem("BPMCSRF_cookie")
},
};
try {
const resp = await fetch(url, data as FetchRequest);
if (resp.status === 500) {
if (!Context.data.is500err) {
Context.data.is500err = true;
return;
} else {
throw new Error(resp.status + ": " + resp.statusText)
}
}
if (resp.status !== 200) {
throw new Error(resp.status + ": " + resp.statusText)
}
Context.data.is500err = false;
const res = await resp.json();
Context.data.createdCount! += res.value.length;
return res.value;
} catch (e) {
throw e;
}
}
async function resetAllParams(): Promise<void> {
Context.data.createdCount = 0;
Context.data.createdEnd = false;
Context.data.iterationsCounter = 0;
Context.data.is500err = false;
}
async function resetCounter(): Promise<void> {
Context.data.iterationsCounter = 0;
}