SAP Scripts на C#

Всем привет! Это моя первая статья на Хабре, я решил создать её здесь, чтобы поделиться наработанным опытом со всеми коллегами по RPA. Речь пойдёт об автоматизации интерфейса SAP через SAP скрипты при разработке на C#. Столкнулся я с этой задачей во время работы с Primo RPA. Я не рекламирую их, но благодаря их сообществу (особая благодарность Alexander) я научился работать со скриптами уже вне самой Primo студии. Мы будем использовать предоставленный ими пакет, который находится здесь: https://disk.primo-rpa.ru/index.php/s/qDycn5l9uJSXGJC (там же pdf-документация, которая нам понадобится).

Почему это важно и удобно? По многим причинам. Например, мой текущий проект связывает внешний API с интерфейсом нашего SAP, а работу с API куда лучше написать при помощи обычного кода в обычных проектах MS VS. Плюс есть некоторые сложные ситуации в интерфейсе, которые Primo RPA (пока) не научилась обрабатывать.

В сети нет какого-либо подробного гайда про работу со скриптами из C#. Поэтому расскажу о скриптах так, как рассказывал коллегам и как помогал в них разбираться. Весь созданный нами статический класс работы со скриптами можно целиком вставить в Pure Code в Primo Studio и творить чудеса там (так собственно мои коллеги порой и поступают). Почему не библиотекой? С их подключением у Primo Studio есть свои сложности, но вы можете поэкспериментировать и найти какой-то удобный вариант.

Преднастройка SAP

Чтобы всё это работало, нужно, чтобы в SAP была включена поддержка скриптов вот таким образом:

Опции SAP GUI

Опции SAP GUI

Создание проекта

Запускаем MS Visual Studio 2022 → Create a new project. Нам нужен .Net Framework, потому что пакет (и библиотеки внутри) работают на нём. Версия 4.8 подойдёт.

Создание нового проекта

Создание нового проекта

Дальше складываем пакет «Interop.SAP.1.0.0.nupkg» в удобную папку. Идём в пакеты проекта (правой кнопкой на проект → Manage NuGet Packages). Выбираем локальную папку в качестве ресурса и устанавливаем его:

Локальное хранилище проектов

Локальное хранилище проектов

Код

Создаём статический класс и подключаем using-и:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using SAPFEWSELib;
using SapROTWr;

namespace RPA.SAPScripts
{
    public static class SapUtils
    {
    }
}

Сам SAP (saplogon.exe) скрипты нам не запустят, поэтому добавим метод запуска (и убийства — тоже пригождается) самого приложения:

public static void LaunchSAP()
{
	Process.Start(@"C:\Program Files (x86)\SAP\FrontEnd\SapGui\saplogon.exe");
	do
	{
		Thread.Sleep(2000);
		if (Process.GetProcessesByName("saplogon").Length > 0)
		{
			Thread.Sleep(2000);
			break;
		}
	} while (true);
}

public static void KillSAP()
{
	foreach (var process in Process.GetProcessesByName("saplogon"))
		process.Kill();
}

Подключение к движку скриптов происходит через обращение к Windows Running Object Table — таблице выполняющихся объектов. Не стану утверждать, что понимаю как она работает, но это в данном случае не важно. Что важно, так это то, что движок скриптов позволяет нам запустить окно (или окна) уже из SAP Logon. При чём для подключения берётся то название, которое вы зададите через свойства подключения (на картинке как раз мне удобное название для одного из тестовых подключений).

Соединения

Соединения

Основной объект для работы с элементами это GuiSession, он понадобится нам везде. И код метода для открытия подключения:

public static GuiSession GetSapSession(string sapSystemName)
{
	CSapROTWrapper sapROTWrapper = new CSapROTWrapper();
	object sapGuiRot = sapROTWrapper.GetROTEntry("SAPGUI");
	if (sapGuiRot == null) throw new Exception("Не удалось получить ROT Entry для SAPGUI");

	// Получаем ссылку на движок скриптов
	object engine = sapGuiRot.GetType().InvokeMember("GetScriptingEngine", System.Reflection.BindingFlags.InvokeMethod, null, sapGuiRot, null);

	// Получаем ссылку на уже открытое окно SAP
	GuiApplication sapApp = (GuiApplication)engine;

	// Открываем соединение и получаем сессию
	GuiConnection connection = sapApp.OpenConnection(sapSystemName);
	GuiSession session = (GuiSession)connection.Children.ElementAt(0);

	return session;
}

Можно заметить, что подключений/сессий может быть несколько. По опыту могу сказать, что больше двух окон/сессий лучше не открывать (шесть это вообще предел). Если есть возможность по времени выполнения обойтись одним (сохраняя данные между транзакциями), то лучше держать одну сессию. К вопросу логина мы перейдём позже, а пока ещё один полезный момент. Часто бывает (особенно при тестировании), что нужно брать уже открытое окно. Я это делаю вот так:

public static GuiSession TryGetCurrentWindowSession()
{
	try
	{
		CSapROTWrapper sapROTWrapper = new CSapROTWrapper();
		object SapGuilRot = sapROTWrapper.GetROTEntry("SAPGUI");
		if (SapGuilRot == null) return null;
		object engine = SapGuilRot.GetType().InvokeMember("GetScriptingEngine", System.Reflection.BindingFlags.InvokeMethod, null, SapGuilRot, null);
		GuiApplication sapApp = (GuiApplication)engine;
		if (sapApp.Connections.Count == 0) return null;
		GuiConnection connection = (GuiConnection)sapApp.Connections.ElementAt(0);
		if (connection.Children.Count == 0) return null;
		GuiSession session = (GuiSession)connection.Children.ElementAt(0);
		return session;
	}
	catch
	{
		return null;
	}
}

Ответ на вопрос «Что за ужасный try/catch?»: Дело в том, что в редких случаях бывают ситуации, когда даже при открытом окне SAP не возвращает сессию. И в таких случаях приходиться его убивать/перезапускать. Если у вас открыто несколько окон, то можно передать как параметр соответствующий номер в sapApp.Connections.ElementAt (number).

SAP запущен. Что дальше? Уже с экрана логина SAP позволяет записывать скрипты. Запускается отсюда:

Запись скрипта

Запись скрипта

Производите нужные действия и затем открываете записанный файл. Например, после введения логина и пароля мы провалились в транзакцию и заполнили там поле. В файле мы получим вот такое:

If Not IsObject(application) Then
   Set SapGuiAuto  = GetObject("SAPGUI")
   Set application = SapGuiAuto.GetScriptingEngine
End If
If Not IsObject(connection) Then
   Set connection = application.Children(0)
End If
If Not IsObject(session) Then
   Set session    = connection.Children(0)
End If
If IsObject(WScript) Then
   WScript.ConnectObject session,     "on"
   WScript.ConnectObject application, "on"
End If
session.findById("wnd[0]").maximize
session.findById("wnd[0]/usr/txtRSYST-BNAME").text = "some_login"
session.findById("wnd[0]/usr/pwdRSYST-BCODE").text = "*********************************"
session.findById("wnd[0]/usr/pwdRSYST-BCODE").setFocus
session.findById("wnd[0]/usr/pwdRSYST-BCODE").caretPosition = 8
session.findById("wnd[0]").sendVKey 0
session.findById("wnd[0]/tbar[0]/okcd").text = "transaction_name"
session.findById("wnd[0]/tbar[0]/btn[0]").press
session.findById("wnd[0]/usr/ctxtPNPPERNR-LOW").text = "12345"

Прежде чем я расскажу как с этим работать, поделюсь методом, который часто будет полезен. Он не требует приведения интерфейсов, а даёт нам знать есть ли элемент на экране. Диалоговые окошки — это тоже элементы, их адрес начинается с "wnd[1]", "wnd[2]" и так далее.

public static bool IsThereElement(GuiSession session, string address)
{
    var guiComponent = session.FindById(address, false);
    if (guiComponent != null) return true;
    else return false;
}

Нам понадобится документация SAP. Она теперь недоступна в РФ. Но нам в любом случае гораздо удобнее будет использовать PDF-версию (она лежит рядом с пакетом, но вот и оригинальная ссылка. На странице 8 начинается перечисление доступных объектов SAP, и вот там есть «ключ» к тому, к какому интерфейсу нужно привести элемент, чтобы с ним взаимодействовать. Например, "wnd[0]/usr/txtRSYST-BNAME" имеет префикс txt, что является префиксом GuiTextField (в тексте pdf-ки: «The type prefix is txt»). Это же позволяет нам не спутать этот элемент с последней строкой из скрипта выше — оба элемента отвечают за ввод текста, но на самом деле они разные. Установка свойств и вызов методов после приведения проходит легко при помощи подсветки MS VS (и чтения документации):

((GuiTextField)session.FindById("wnd[0]/usr/txtRSYST-BNAME")).Text = login;
((GuiPasswordField)session.FindById("wnd[0]/usr/pwdRSYST-BCODE")).Text = password;

Конкретные интерфейсы далеко не всегда очевидны, но это уже опыт Индианы Джонса — раскопать нужный интерфейс. Для окон это GuiFrameWindow. Он же используется для отправки клавиш через .SendVKey (keyNumber). Такие вещи как .setFocus и .caretPosition нам в данном случае не нужны (я в принципе за всё время их ни разу не использовал), поэтому мы получаем вот такой вот код:

public static void Login(GuiSession session, string login, string password)
{
	var frame = (GuiFrameWindow)session.FindById("wnd[0]");
	frame.Maximize();
	((GuiTextField)session.FindById("wnd[0]/usr/txtRSYST-BNAME")).Text = login;
	((GuiPasswordField)session.FindById("wnd[0]/usr/pwdRSYST-BCODE")).Text = password;
	frame.SendVKey(0);
}

public static void EnterTheTransactionAndDoSomething(GuiSession session, string transactionName)
{
	((GuiVComponent)session.FindById("wnd[0]/tbar[0]/okcd")).Text = transactionName;
	((GuiButton)session.FindById("wnd[0]/tbar[0]/btn[0]")).Press();
	((GuiCTextField)session.FindById("wnd[0]/usr/ctxtPNPPERNR-LOW")).Text = "12345";
}

Есть огромное количество сложных/интересных случаев, но для вступительной статьи я думаю, что пока достаточно. Один только момент напоследок: SAP-еры очень не любят, когда SAP закрывают через убийство процесса. Поэтому закрытие SAP лучше делать через встроенную функцию /nex в поле для транзакции:

public static void CloseSAPWindowByNex(GuiSession session)
{
    ((GuiVComponent)session.FindById("wnd[0]/tbar[0]/okcd")).Text = "/nex";
    ((GuiFrameWindow)session.FindById("wnd[0]")).SendVKey(0);
}

© Habrahabr.ru