Русский
Русский
English
Статистика
Реклама

Автоматизация рутины

Как нарисовать холдинг, цепочки владения и посчитать доли КИК

28.10.2020 18:06:09 | Автор: admin
В юридической практике корпоративных юристов относительно недавно (несколько лет назад) появилась необходимость составлять и подавать уведомления о контролируемых иностранных компаниях (КИК) в рамках ст. 25.13 НК РФ. Суть этой обязанности составить и подать документ, в котором будут отражены все связи общества в холдинге по цепочкам от текущего ООО (АО) в РФ до владельца- налогового резидента РФ КИК. Говоря проще, если офшором владеет россиянин (налоговый резидент РФ), а офшор российским ООО (даже через забор промежуточных ООО) более 25 % уведомлению быть. Изюминка в том, что подавать необходимо всем ООО (АО) в которых эта ситуация наблюдается и подавать как сведения о владении более 25%, так и последующие изменения доли владения своевременно, иначе штрафы (100 000 рублей по каждой компании в цепочке ст. 129.6 НК РФ). Так как холдинг (совокупность юр. лиц) организм живой и постоянные изменения долей владения неизбежны, за всем этим надо как-то следить, чтобы не насобирать штрафов. Как упростить работу в данном направлении, автоматизировать ее, посвящена данная статья. Статья также будет интересна с точки зрения графического представления связанных структур, например соц. сетей.


В данной статье не будем останавливаться на юридических аспектах подаваемых уведомлений о КИК, об участии в КИК, рассмотрим техническую сторону вопроса.
Бесспорно, если холдинг, о котором идет речь представляет себя простые структуры вида ООО->КИК->россиянин, то, что-то строить здесь с привлечением машины нецелесообразно, другое дело, если структура ветвится, двоится и нет числа этим сплетениям.
Рассмотрим несколько существующих графических решений, которые упростят работу.
Для удобства визуализации будет использоваться среда jupyter notebook и python.

Networkx.


Данное решение самое древнее из представленных и не может похвастаться своей интерактивностью. О данном пакете есть такая же древняя статья на Хабре.
Однако старое не значит плохое, и данный вариант один из наиболее удачных как в плане рисования, так и в вычислительном.
Установим и импортируем модуль через jupyter:
!pip install networkximport networkx as nx

Также импортируем иные доп. модули, которые помогут нарисовать фигуры:
from matplotlib import pyplot as plt%matplotlib inlineplt.rcParams.update({    'figure.figsize': (7.5, 7.5),    'axes.spines.right': False,    'axes.spines.left': False,    'axes.spines.top': False,    'axes.spines.bottom': False})

Построим с помощью networkx первую сеть:
from pathlib import Pathdata_dir = Path('.') / 'data'# Read edge listG = nx.read_edgelist('example.edgelist')# Draw network#pos = nx.spring_layout(G)pos = nx.spectral_layout(G)#pos = nx.planar_layout(G)nx.draw_networkx(G, pos)plt.gca().margins(0.15, 0.15)

Вот, что получилось:

Как видно, Иванов владеет двумя КИКами, которые, в свою очередь, владеют российскими юр. лицами.

Разберем код выше.
Импортировали модуль и указали откуда будем считывать данные на диске:
from pathlib import Pathdata_dir = Path('.') / 'data'

В текущей директории считали 'example.edgelist':
G = nx.read_edgelist('example.edgelist')

*example.edgelist это обычный текстовый файл вида:
# source targetИванов КИК1Иванов КИК2КИК1 КИК2КИК1 Ромашка_ОООКИК2 Ведро_АО

Значения записаны кто-кем владеет с пробелом между ними.
Далее определили как будет выглядеть сеть:
pos = nx.spectral_layout(G)

Если поменять на pos = nx.spring_layout(G), то она примет вид:

И это расположение, как ни странно, наиболее подходящее для более масштабных структур.
Наконец, нарисовали сеть, обозначив отступы:
nx.draw_networkx(G, pos)plt.gca().margins(0.15, 0.15)

Сохранить картинку просто:
plt.savefig('plot.png')


Как нарисовать сегмент в networkx.


#подграфикH = G.subgraph(['Иванов', 'КИК1', 'Ромашка_ООО'])plt.subplot(212) print("Подграфик:") nx.draw_networkx(H)

Здесь мы отступы не сделали, и названия уехали:


Networkx оперирует понятиями нод(nodes) и связей(edges) между ними. В нашей ситуации ноды это Иванов, КИК1, КИК2, Ромашка_ООО, Ведро_АО. А связи то, что находится в файле example.edgelist.
Посмотреть и то и другое можно просто, обратившись к методам G.nodes и G.edges:


Направленный график в networkx (Directed edge list).


Проясним немного построенную сеть, добавим стрелочки:
# Read edge listG = nx.read_edgelist(    str('example.edgelist'),    create_using=nx.DiGraph)pos = nx.spectral_layout(G)# Draw networknx.draw_networkx(G, pos, arrowsize=20)plt.gca().margins(0.15, 0.15)

Небольшие изменения позволили нарисовать более внятную картину кто кем владеет:

В коде, как можно заметить, изменения минимальны.

Следующий этап построение графика, где будут видны размеры пакетов владения.
Для этого надо познакомиться с понятием веса (weight) это третий основной параметр, с которым может работать networkx. Чтобы его включить в работу, в текстовый файл надо добавить эти самые веса, например так:
# source targetИванов КИК1 100Иванов КИК2 100КИК1 КИК2 50КИК1 Ромашка_ООО 100КИК2 Ведро_АО 100

Теперь заново построим сеть, используя уже веса и обозначим их на графике:
# Read edge listG = nx.read_weighted_edgelist(    str('example2.edgelist'))# Extract weightsweights = [d['weight'] for s, t, d in G.edges(data=True)]nx.draw_networkx(G,pos)labels = nx.get_edge_attributes(G,'weight')nx.draw_networkx_edge_labels(G,pos,edge_labels=labels)plt.gca().margins(0.15, 0.15)

*example2.edgelist это файл, который сформирован выше с весами.
Получим вот такую картину:


Кто кем и как владеет, networkx.


Теперь нам как юристам-программистам, надо понять, в какой последовательности и в каком размере владеет Иванов, например компанией Ведро_АО и владеет ли вообще. Это требуется, чтобы определить в разветвленном холдинге факт владения и все цепочки до целевой ООО (АО), чтобы потом эти цепочки прописать в уведомление по КИК.

С помощью networkx сделать это можно следующим образом:
list(nx.all_simple_paths(G,'Иванов', 'Ведро_АО'))

В качестве первого аргумента идет нода-владелец, вторым нода, до которой мы будем строить пути.
Используя данный метод можно увидеть, что Ведром_АО Иванов владеет по следующим цепочкам:
[['Иванов', 'КИК1', 'КИК2', 'Ведро_АО'], ['Иванов', 'КИК2', 'Ведро_АО']]

Графически это подтверждается.
Узнать долю владения можно перемножив веса между соответствующими нодами: 1*0,5*1=0,5, 1*1=1. Доля более 25%, уведомление необходимо подавать.
В коде перемножение делается следующими костылями (более изящный метод пока не найден):
x=0b=0c=[]for i in list(nx.all_simple_paths(G,'Иванов', 'Ведро_АО')):        for a in i:                if x>len(i)-2:            pass                                else:                        b=int(nx.bidirectional_dijkstra(G, i[x],i[x+1])[0])#доля владения                                   x+=1            c.append(b/100)              print(c)import numpy as npprint(np.prod(c))

x=0b=0c=[]for i in list(nx.all_shortest_paths(G,'Иванов', 'Ведро_АО')):    for a in i:                if x>len(i)-2:            pass                              else:                        b=int(nx.bidirectional_dijkstra(G, i[x],i[x+1])[0])#доля владения                                   x+=1            c.append(b/100)              print(c)import numpy as npprint(np.prod(c))

В первом случае выведет долю 0,5, во втором 1.

Какие еще есть доступные варианты визуализации? Например, Netwulf.

Netwulf


Документация находится здесь.

Сама сеть интерактивна, в этом ее основной плюс. После установки python пакета, построим сеть:
import netwulf as nwplt.figure(figsize=(200,200))G = nx.read_weighted_edgelist(str('example2.edgelist'),create_using=nx.DiGraph)pos = nx.spring_layout(G)nw.visualize(G)

После запуска кода, jupyter подвисает, а в дополнительно открывшемся окне браузера виден результат:

Справа на панели видны опции, изменение которых влияет в режиме онлайн на построенную сеть.
Минус данного пакета пока нельзя отобразить веса и стрелки между нодами, но авторы обещали это доработать.
*чтобы вернуться в jupyter понадобится нажать на опцию post to python:


Еще один неплохой вариант подобной визуализации для python молодой проект webweb.

Webweb.


Документация здесь.
Строится сеть схожим образом:
from webweb import Webweb = Web(title='kitchen_sink')web.display.networkName = 'tree'web.display.networkLayer = 2web.display.colorBy = 'ring'web.display.sizeBy = 'degree'web.display.gravity = .3web.display.charge = 30web.display.linkLength = 15web.display.colorPalette = 'Greens'web.display.scaleLinkOpacity = Falseweb.display.scaleLinkWidth = Truefrom pathlib import Pathdata_dir = Path('.') / 'data'# Read edge listG = nx.read_edgelist('example.edgelist',create_using=nx.DiGraph)plt.figure(figsize=(200,200))# Draw networkpos = nx.spring_layout(G)Web(list(G.edges)).show()



Из явных преимуществ перед netwulf: возможность выделения цветом ключевых нод, текстовый поиск нод с подсветкой на сети:


Резюмируя, можно сказать, что развивающиеся потомки networkx netwulf и webweb хороши для построения быстрой картинки структуры небольшого холдинга. У обоих модулей есть режим freeze, чтобы заморозить ноды, которые слипаются в одну кучу в силу интерактивности графика. Однако даже используя их непросто работать с масштабными структурами, где количество нод больше 200.

Подножка от Минфина, перекрестное и кольцевое владение.


Все было бы совсем хорошо при построении подобных структур, если бы не одно но, которое портит всю картину. Это но заключается в том, что в холдингах общества владеют сами собой через другие юр. лица и это называется либо перекрестное либо кольцевое владение.
На картинках в письмах от Минфина (например от 02.07.2013 ОА-4-13/11912) это выглядит так.
Перекрестное владение:

Кольцевое:


Посмотрим, как определит связи networkx для схемы перекрестного владения участия D в B.
Создадим edgelist со связями:
# source targetD B 45B A 40A B 55E A 60

Построив сеть с весами, можно увидеть, что обратная связь между A и B не отражена:

Ее можно увидеть, если построить сеть без весов, со стрелками:


Что с расчетами? Какова совокупная доля D в B?
Тут кажется все прозрачно, 45%
И networkx выдает при команде list(nx.all_simple_paths(G,'D', 'B')):
[['D', 'B']]
Но не все так просто.
Минфин говорит, совокупная доля D в B определяется по формуле:

И составит 57,69%.

Что делать? networkx бессильна?
Вовсе нет, networkx позволит выявить подобные ситуации, но вот формула расчета будет другой, согласно букве Закона.
Частично проблему можно снять, добавив в edgelist записи
A A
B B
Далее командой list(nx.nodes_with_selfloops(G)) можно посмотреть ноды с участием в самих себе, но при определении путей из D в B это все равно не учитывается.

jupyter тетрадка скачать здесь.
Подробнее..

Как мы автоматизировали выгрузки и другие Ad-hoc задачи аналитика с помощью Zeppelin

16.12.2020 18:10:53 | Автор: admin

На момент написания этой статьи в компании Cardsmobile, которая разрабатывает мобильное приложение Кошелёк, работает 195 человек: 8 аналитиков и 187 потенциальных заказчиков аналитиков. Мы делаем приложение для конечных пользователей, а также работаем с ритейлом, банками, брендами и другими партнерами. Долгое время работа аналитика в Кошельке состояла не только из исследований поведения пользователя, но и из различных выгрузок, типовых анализов для партнеров и прогнозов для потенциальных клиентов. Конечно, дашборды сильно спасали нам жизнь и позволяли всей компании следить за показателями продукта. Но мы всё ещё тратили время на остальную текучку, и с ростом команды (заказчиков) и бизнеса упёрлись: Ad-hoc задач стало слишком много, а исследования, желание развиваться и светлое будущее простаивали в отсутствие у нас времени.


Так много вокруг классных конференций, интересных статей про различные аналитические исследования, data-science, data-driven, data-счастье. А мы смотрели на всю эту красоту и не знали, где среди всего потока текучки найти время на эксперименты. Многие рассказывают, как сделать классно, но мало кто рассказывает, КАК преодолеть нарастающую текучку и освободить ресурсы для интересных и творческих задач. В этой статье я расскажу про наш опыт выхода в светлое будущее. Дальше будут примеры, как мы автоматизируем Ad-hoc задачи аналитиков в Zeppelin.


Что такое Zeppelin


Zeppelin это OpenSource Notebook от Apache, который позволяет обращаться к различным БД на разных языках (Python, R, SQL, Spark). Но что делает его особенно кайфовым, так это набор визуальных элементов dynamic forms.


В одном ноутбуке мы можем извлекать данные по api из Amplitude, быстро считать агрегаты из Clickhouse, дополнять результат данными из MSSQL и обрабатывать все это на Python. А готовые отчеты заворачивать в Excel в удобном заказчику формате и класть в html-ссылку, по которой их можно легко скачать.


Изначально мы начали использовать его просто как notebook, в котором было удобно писать на разных языках. Потом изучили возможности Zeppelin получше, нашли встроенные динамические формы: инбоксы, выпадающие списки и чеклиты лампочка над головой загорелась! Сразу придумали, сколько всего мы можем автоматизировать. У нас было много типовых задач с готовым кодом, в котором надо было просто менять значения переменных. Мы перенесли весь наш код в Zeppelin, вынесли переменные в динамические формы и дали заказчикам возможность самостоятельно заполнять их и запускать скрипты. Идея понравилась и нам, и всей остальной команде! :)


Какие динамические формы есть


Input текстовое поле. Мы используем его, для того чтобы задать временной диапазон для ввода идентификаторов. Другими словами, для всего, вариаций чего бывает много.


image


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


image


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


image


Какие задачи мы автоматизируем в Zeppelin


Выгрузки простые и сложные, с использованием фильтров по дате, партнеру, задавая набор столбцов.


Чаще всего запросы на выгрузки приходят от аккаунт-менеджеров. А еще, с большой вероятностью, они поступают внезапно и срочно. Сами по себе задачи на выгрузку типовые и быстрые в выполнении. Но в действительности они отвлекают от тех самых интересных аналитических исследований, и их число растет по мере роста партнерской сети.


С какими задачами обычно приходят:


  • Потенциальный партнер хочет оценить ту аудиторию, которая уже пользуется его картой лояльности в нашем приложении. Наши менеджеры по продажам могут прямо на встрече зайти в Zeppelin и выгрузить список карт. Они передают материалы еще до того, как диалог и интерес к нашему сервису начинают остывать. Мы не оценивали, но, возможно, это способствует ускорению продаж.
  • К нашей партнерской сети подключается новый магазин. Его карты уже существовали в пластиковом формате, наши пользователи могли добавлять их в приложение и показывать штрихкод на кассе. Но с подключением мы дали партнеру новые возможности: теперь у пользователя под картой появилась информация о спецпредложениях, персональные скидки. Менеджер на стороне партнера хочет оценить, как изменилось покупательское поведение среди тех, кто добавил их карту в приложение Кошелёк. Наш аккаунт-менеджер помогает ему выгружает все карты номера и штрихкоды карт, которые уже существовали ранее на пластиковом носителе, выпущенные в какой-то определенный период.
  • Партнер хочет повысить продажи определенного товара и запускает акцию. Он информирует свою аудиторию в приложении Кошелёк о специальных скидках на товар, используя пуш-сообщения. Чтобы оценить эффективность этой коммуникации, мы выгружаем ему отчет о том, кто получил и прочитал данный пуш.

Мы создали отчеты для всех частых задач на выгрузки, с которыми к нам обращались. Ускорили процессы наших коллег и высвободили время и внимание для более интересных задач. Теперь только дорабатываем эти отчеты по мере необходимости.


image


Типовые задачи, в которых надо просто запустить готовый скрипт. Тут тоже применяем фильтры, даём задать значение переменных. Например, пересчет какой-то метрики или отчета, которые используются редко и не хочется ставить их на расписание.


Более изощренный кейс из жизни. Отдел маркетинга совместно с нашими стратегическими партнерами решили провести промо-акцию с определенной механикой. Пользователи нашего приложения должны были совершить цепочку действий, становясь участниками розыгрыша подарков. Раз в неделю мы хотели получать список участников недели, рандомно определять победителей, поздравлять их и отправлять подарки. Аналитик направления создал notebook в Zeppelin, который собирал пользователей, соответствующих условиям участия в розыгрыше за прошедшую календарную неделю. Маркетолог самостоятельно запускал notebook и забирал участников недели.


Подведение итогов А/B-тестов, измерение base-line метрик в тестовой и контрольной группах. Когда мы тестируем новый функционал или триггерную коммуникацию, мы смотрим не только на изменение целевой метрики, но и на то, как в целом меняется поведение пользователя. Мы выделили 4 base-line метрики пользовательского поведения:


  • Активность в приложении
  • Выпуски карт лояльности и других продуктов
  • Отписки
  • Обращения в саппорт

Тут Zeppelin дает нам свободу в том, как мы хотим подводить итоги, какие метрики считать, как отрисовывать графики и как объяснять результат тем, кто будет пользоваться этим инструментом.


image


Собираем базы для коммуникаций и ретаргетинговых кампаний на основе выгружаемой из Amplitude когорты. Когда-то мы отказались от готовых коммуникационных платформ в пользу собственной разработки (возможно, это тема для отдельной статьи, а мы тут не об этом). Наше внутреннее решение было в первую очередь заточено под партнерские рассылки: выбери партнера и отправь сообщение на всю базу. А вот подготовка баз для продуктовых и маркетинговых коммуникаций то есть собственных коммуникаций Кошелька легла на плечи аналитиков. Типизировать все запросы от маркетинга и продактов казалось невозможным. Мы все стремились выделить наиболее релевантные сегменты, не ограничивая свои возможности. Например, вымышленный запрос, но запросы аналогичной сложности с нами случались:


  • Пользователи, которые пришли в период
  • Добавили 5 или менее карт из топ 10 программ лояльности
  • Начали проходить определенный сценарий, но не закончили
  • Пользовались приложением больше 2х раз за последний месяц
  • И можно добавить фильтры по модели устройства, сотовому оператору и географии

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


И все это так и было, пока один аналитик не обленился достаточно, чтобы не писать код для выборки пользователей в Clickhouse, а собрать когорту в Amplitude и выгрузить ее по api. Что, согласитесь, сильно проще и быстрее. Привычный и уже понятный интерфейс Amplitude, где любой менеджер может самостоятельно собрать когорту со всеми фильтрами из примера выше, проверить ее размер, дополнительно проконтролировать себя и проверить пользователей из когорты, что они попали в нее верно.


Как выглядит механика:


  • Продакт-менеджер или маркетолог создает когорту в Amplitude. При необходимости сложные кейсы показывает аналитику.
  • Копирует id когорты, который находится в адресной строке
  • Вставляет в notebook в Zeppelin
  • Выставляет дополнительные фильтры, данных для которых нет в Amplitude
  • Присваивает рассылке уникальный sub_id и запускает notebook

Что происходит в это время:


  • Скрипт берет id когорты и выгружает ее по api из Amplitude
  • Полученный DataFrame очищается от лишних строк в Python
  • При необходимости база получателей дополнительно фильтруется по полу и/или возрасту
  • Так же выделяется контрольная группа, если мы хотим измерить эффективность рассылки (а мы редко не хотим)
  • Получатели записываются в БД для истории и передаются в csv-файл, который мы для удобства скачивания кладем в кликабельную ссылку

Я привела пример именно с пуш-рассылками, но у наших коллег быстро возникли идеи, где еще мы можем применять похожий инструмент: любые выгрузки списка пользователей с определенным пользовательским поведением. Сейчас мы используем когорты из Amplitude еще и для запуска ретаргетинговых кампаний. И, думаю, будем использовать и для многих других задач.


image


Системы мониторинга
Есть еще одна удобная фича, которая правда не относится к динамическим формам и, наверное, не совсем про автоматизацию запуск по расписанию. Мы используем ее для пересчета дашбордов, запуска разных расчетов. Но самая полезная аналитическая задача, которую мы с ее помощью решаем, это мониторинг. Аномалии в событиях, в поведении метрик, что угодно, за чем должен регулярно следить аналитик, но что тоже хочется автоматизировать. Мы настроили систему алертов в slack и теперь можем вовремя реагировать на изменения, о которых хотим знать:


  • Рост или падение важных продуктовых показателей, конверсий сценариев, которые отражают качество пользовательского опыта и влияют на Retention.
  • Рост числа ошибок, которые могут возникать у пользователя. Не все такие аномалии могут отразиться на росте количества обращений в support. Многие могут повлиять на ухудшение конверсий и в итоге увеличить отток. И даже если они не критичны, а просто доставляют неудобство нашей аудитории, нам важно вовремя о них узнать и сократить их число.
  • Просто аномалии в количестве всех событий и каждого в отдельности. Такой мониторинг позволяет нам отлавливать кейсы, о которых мы не подумали заранее.
  • Мы так же настроили алерт о том, что какие-то из наших регулярных расчетов, которые работают в Zeppelin по расписанию, отработали с ошибкой. Мы создаем много полезных инструментов, но не можем постоянно вручную следить за их качеством.

Success. Победили текучку, освободили время для развития аналитики в компании


Самый приятный абзац этой статьи наступило светлое будущее! Мы уже автоматизировали большую часть наших Ad-hoc задач. Теперь в спринте их меньше 10%. В освободившееся время мы проводим исследования, выдвигаем и проверяем гипотезы, усложняем наши продукты аналитики и применяем подходы из тех самых статей и выступлений на конференциях. Другими словами, мы наконец занимаемся интересной аналитической работой. А главное, у нас появилось время принимать активное участие в развитии Кошелька.


Совет начинающим автоматизаторам: все частые и типовые куски кода выносите в библиотеки. Это позволит писать быстрее, улучшать качество написания кода всей команды аналитиков и править код в одном месте, а не во всех ноутбуках. И не забывайте, что вы делаете инструмент не для себя, а для своих коллег. А у них разный бэкграунд. Не пугайте их сложными интерфейсами и формулировками, делайте проще и понятнее.


Data-счастье еще впереди, но мы уже сильно воодушевились, ожили и бежим ему навстречу.

Подробнее..

Фронт без релиз-инженера, или Как я перестал бояться и полюбил деплой

08.02.2021 14:10:06 | Автор: admin

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

В нашей компании самостоятельный деплой это часть workflow многих разработчиков. Такая практика типична, скорее, для стартапов и редка в средних и крупных компаниях. Почему? Мне кажется, решение о полной передаче деплоя выделенным специалистам, играющим роль релиз-инженеров, происходит по вполне понятным причинам:

  • Разделение ответственности в ряде случаев ответственность за деплой оказывается высокой.

  • Необходимость в дополнительных навыках обычно нужно неплохо знать платформу, через которую происходит деплой, или хотя бы разбираться в скриптах вроде Capistrano.

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

Как это происходит у нас

В Учи.ру был создан собственный инструмент для выкатки сервисов и настройки стейджей под названием Шаман по сути кастомный интерфейс к HashiCorp Nomad. Несколько лет назад он помог уйти от исполнения скриптов на локальных машинах, автоматизировать процесс деплоя и позволил разработчикам создавать тестовые конфигурации максимально быстро. Об этом мои коллеги из команды инфраструктуры написали отдельную статью.

Немного истории

С самого начала вся разработка у нас велась внутри одного-единственного монолитного репозитория, поэтому Шаман умел собирать его и выкатывать, при этом отлично справляясь со своей задачей. Но компания росла, а вместе с ней осваивались рынки, запускались новые проекты, добавляя перекрестные логические связи и в без того сложный код.

Но аве прогрессу подходы к разработке эволюционируют вместе с нами. Поэтому в какой-то момент, наевшись боли от того, что в монолит коммитят 200 человек, мы потихоньку начали откусывать от него небольшие семантически связанные куски и выносить их в сервисы. Со временем сервисов отпочковывалось все больше, а им, естественно, нужно как-то коммуницировать друг с другом сложность настройки приложений экспоненциально увеличивалась.

Да придет спаситель

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

А что делать, если тебе нужно поднять копию среды для тестирования второго релиза? Не создавать же всю связку целиком заново? А если ее незначительно видоизменить? Для подобного в Шамане есть возможность создать шаблон конфигурации, в котором с помощью YAML описана вся россыпь машин, сервисов и эндпоинтов. В результате, если ваша команда поддерживает свой шаблон, поднятие нового инстанса развесистой связки сервисов становится тривиальной задачей.

И, кажется, коллеги со мной согласны:

Лично для меня в деплое нет ничего сложного. И учитывая, что иногда мне приходится делать больше десяти тестовых выкаток в день, а также развертывания совершенно новых приложений, я очень рад, что не нужно каждый раз подключать кого-то из отдела инфраструктуры. Приложений у нас много, и возможность самостоятельной выкатки сильно упрощает жизнь и ускоряет работу, Роман Литвинов, ROR разработчик.

Схема связи Шамана с кодом

На данный момент почти все наши GitHub-репозитории собираются при помощи GitHub Actions, в результате чего докер-образ пушится в общую приватную докер-репу. Этот workflow можно триггерить как автоматически (пуш в релизную ветку), так и руками, если хочется потестить какой-то сиюминутный фикс. Дальше Шаман подцепляет свежий образ и по кнопке раскатывает его на стейдж. Ну не красота, а?

И так как этот процесс находится в ведении разработчиков чуть более чем полностью, у нас есть возможность его упрощать. Меня, например, все-таки расстраивает необходимость сначала идти в GitHub, чтобы посмотреть статус билда, а потом в Шаман чтобы нажать на заветную зеленую кнопку для выкатки образа. После незначительной встряски коллег из инфраструктуры выяснилось, что последний предоставляет API, ручку которого можно дернуть из Github Actions с адресом для деплоя и идентификатором образа для деплоя. А это значит, что деплоить код можно полностью автоматически!

Когда что то идёт не так

Понятное дело, что после выкатки кода на прод надо убедится, что он ведёт себя так, как задумано. И тут у нас чуть хуже с автоматизацией, но тем не менее всё под контролем. Для мониторинга мы используем сервис Sentry, который нотифицирует как о новых индивидуальных ошибках, так и об изменениях общего фона. Ну и продуктовые дашборды с метриками никто не отменял. А Шаман, в свою очередь, быстро и непринужденно позволяет откатить бажную версию фронтового приложения в случае серьезных проблем.

Так нужно ли деплоить разработчику?

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

Такая схема позволяет не аккумулировать сделанные задачи в большие релизы, которые долго тестируются, а выкатывать их по мере готовности. Больше контроля и меньше вероятность ошибки.

Ну и вопрос времени: деплоить самому куда быстрее, чем отдавать релиз выделенным инженерам. Но, естественно, с допущениями:

  • Сервисы приходят и уходят, соответственно, шаблон связки сервисов нужно держать актуальным, иначе поднятие новых тестовых стендов превратится в боль.

  • Стоит держать в голове, что мы работаем с in-house-решением со всеми вытекающими: оно может сломаться. Поэтому здесь нужно быть достаточно открытым и помогать коллегам чинить проблему, а не бранить решение.

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

Как происходит деплой в вашей компании? Считаете ли вы наш подход с одной кнопкой оптимальным или вам нравятся другие варианты выкатки новых сервисов и обновлений? Поделитесь мнением в комментариях.

Подробнее..

Как обновить все сцены Unity-проекта в один клик

30.05.2021 12:21:38 | Автор: admin
Танюшка - автор канала IT DIVA и данной статьи, кофеголик и любитель автоматизировать рутинуТанюшка - автор канала IT DIVA и данной статьи, кофеголик и любитель автоматизировать рутину

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

На этом движке, не смотря на его недостатки, создано большое количество крупных проектов с кучей сцен, которые приходится модифицировать со временем по мере развития проекта. И чем больше этих сцен, тем дольше может быть процесс их обновления.

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

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

А далее вы уже сможете использовать и модифицировать приведённый в примере код под свои нужды.

Зачем нужен такой инструмент

Представьте, что у вас есть 3 уровня в вашем проекте. Вы добавили новые скрипты, изменили интерфейс, обновили используемые 3D-модели. Но только в рамках одной сцены.

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

Но что будет, когда их станет 10? 20? 50?

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

Как эту проблему решить?

На самом деле, довольно просто!

Можно поступить несколькими способами. Например, использовать метод OnValidate() в классах, которые уже присутствуют на сцене. Но для этого нужно запустить каждую сцену и сохранить её вручную. Более того, не весь функционал изменения сцен нам будет доступен через OnValidate(), поскольку данный метод есть только у объектов, наследованных от MonoBehaviour.

Нам такой вариант не подходит. Но знать о нём тоже полезно.

Поэтому мы пойдём другим путём и напишем скрипт, который автоматически обновит каждую сцену и выполнит любые необходимые изменения. Написав такой скрипт один раз, вы точно сэкономите себе уйму времени в будущем.

Чтобы это сделать, мы создадим новый класс в папке Editor:

Пример возможной иерархии для расширений движкаПример возможной иерархии для расширений движка

Обращаю внимание, на то, что название некоторых папок в Unity играет роль. В данном случае, в папке "Editor" мы будем хранить все скрипты, которые помогают нам расширить базовый функционал редактора Unity и, например, создавать диалоговые окна.

Далее добавим необходимые пространства имён, а также укажем, что наследоваться будем от Editor Window (а не от MonoBehaviour, как происходит по умолчанию):

using UnityEngine;using UnityEditor;public class SceneUpdater : EditorWindow{    [MenuItem("Custom Tools/Scene Updater")]    public static void ShowWindow()    {        GetWindow(typeof(SceneUpdater));    }        private void OnGUI()    {        if (GUILayout.Button("Update scenes"))    Debug.Log("Updating")    }}

С помощью атрибута [MenuItem("Custom Tools/Scene Updater")] мы создадим элемент меню с заданной иерархией в самом движке. Таким образом мы будем вызывать диалоговое окно будущего инструмента:

Новый элемент меню, через который мы будем использовать наш инструментНовый элемент меню, через который мы будем использовать наш инструмент

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

using UnityEngine;using UnityEditor;public class SceneUpdater : EditorWindow{    [MenuItem("Custom Tools/Scene Updater")]    public static void ShowWindow()    {        GetWindow(typeof(SceneUpdater));    }        private void OnGUI()    {        if (GUILayout.Button("Update scenes"))    Debug.Log("Updating")    }}

А теперь напишем несколько полезных функций, которые лично я довольно часто использую.

Быстрое добавление компонентов к объектам

Для добавления компонентов к объектам с уникальными именами можно написать вот такую функцию:

/// <summary>/// Добавление компонента к объекту с уникальным названием/// </summary>/// <param name="objectName"> название объекта </param>/// <typeparam name="T"> тип компонента </typeparam>private void AddComponentToObject<T>(string objectName) where T : Component{    GameObject.Find(objectName)?.gameObject.AddComponent<T>();}

Использовать её можно вот так:

AddComponentToObject<BoxCollider>("Plane");AddComponentToObject<SampleClass>("EventSystem");

Это немного быстрее, чем каждый раз прописывать всё самостоятельно.

Быстрое удаление объектов по имени

Аналогично можно сделать и для удаления объектов:

/// <summary>/// Уничтожение объекта с уникальным названием/// </summary>/// <param name="objectName"> название объекта </param>private void DestroyObjectWithName(string objectName){    DestroyImmediate(GameObject.Find(objectName)?.gameObject);}

И использовать так:

DestroyObjectWithName("Sphere");

Перенос позиции, поворота и размера между объектами

Для компонентов Transform и RectTransform можно создать функции, с помощью которых будет происходить копирование локальной позиции, поворота и размера объекта (например, если нужно заменить старый объект новым или изменить настройки интерфейса):

/// <summary>/// Копирование позиции, поворота и размера с компонента Transform у одного объекта/// на такой же компонент другого объекта./// Для корректного переноса координат у parent root объеков должны быть нулевые координаты/// </summary>/// <param name="objectToCopyFrom"> объект, с которого копируются части компонента </param>/// <param name="objectToPasteTo"> объект, на который вставляются части компонента </param>/// <param name="copyPosition"> по умолчанию позиция копируется, с помощью данного параметра это можно отключить </param>/// <param name="copeRotation"> по умолчанию поворот копируется, с помощью данного параметра это можно отключить </param>/// <param name="copyScale"> по умолчанию размер копируется, с помощью данного параметра это можно отключить </param>private static void CopyTransformPositionRotationScale(GameObject objectToCopyFrom, GameObject objectToPasteTo,     bool copyPosition = true, bool copeRotation = true, bool copyScale = true){    var newTransform = objectToCopyFrom.GetComponent<Transform>();    var currentTransform = objectToPasteTo.GetComponent<Transform>();            if (copyPosition) currentTransform.localPosition = newTransform.localPosition;    if (copeRotation) currentTransform.localRotation = newTransform.localRotation;    if (copyScale) currentTransform.localScale = newTransform.localScale;}    /// <summary>/// Копирование позиции, поворота и размера с компонента RectTransform у UI-панели одного объекта/// на такой же компонент другого объекта. Не копируется размер самой панели (для этого использовать sizeDelta)/// Для корректного переноса координат у parent root объеков должны быть нулевые координаты/// </summary>/// <param name="objectToCopyFrom"> объект, с которого копируются части компонента </param>/// <param name="objectToPasteTo"> объект, на который вставляются части компонента </param>/// <param name="copyPosition"> по умолчанию позиция копируется, с помощью данного параметра это можно отключить </param>/// <param name="copeRotation"> по умолчанию поворот копируется, с помощью данного параметра это можно отключить </param>/// <param name="copyScale"> по умолчанию размер копируется, с помощью данного параметра это можно отключить </param>private static void CopyRectTransformPositionRotationScale(GameObject objectToCopyFrom, GameObject objectToPasteTo,    bool copyPosition = true, bool copeRotation = true, bool copyScale = true){    var newTransform = objectToCopyFrom.GetComponent<RectTransform>();    var currentTransform = objectToPasteTo.GetComponent<RectTransform>();            if (copyPosition) currentTransform.localPosition = newTransform.localPosition;    if (copeRotation) currentTransform.localRotation = newTransform.localRotation;    if (copyScale) currentTransform.localScale = newTransform.localScale;}

Причём, благодаря тому, что есть переменные-условия, мы сможем контролировать, какие параметры мы хотим скопировать:

var plane = GameObject.Find("Plane");var cube = GameObject.Find("Cube");CopyTransformPositionRotationScale(plane, cube, copyScale:false);

Изменение UI-компонентов

Для работы с интерфейсом могут быть полезны функции, позволяющие быстро настроить Canvas, TextMeshPro и RectTransform:

/// <summary>/// Изменение отображения Canvas/// </summary>/// <param name="canvasGameObject"> объект, в компонентам которого будет производиться обращение </param>/// <param name="renderMode"> способ отображения </param>/// <param name="scaleMode"> способ изменения масштаба </param>private void ChangeCanvasSettings(GameObject canvasGameObject, RenderMode renderMode, CanvasScaler.ScaleMode scaleMode){    canvasGameObject.GetComponentInChildren<Canvas>().renderMode = renderMode;    var canvasScaler = canvasGameObject.GetComponentInChildren<CanvasScaler>();    canvasScaler.uiScaleMode = scaleMode;    // выставление стандартного разрешения    if (scaleMode == CanvasScaler.ScaleMode.ScaleWithScreenSize)    {        canvasScaler.referenceResolution = new Vector2(720f, 1280f);        canvasScaler.matchWidthOrHeight = 1f;    }} /// <summary>/// Изменение настроек для TextMeshPro/// </summary>/// <param name="textMeshPro"> тестовый элемент </param>/// <param name="fontSizeMin"> минимальный размер шрифта </param>/// <param name="fontSizeMax"> максимальный размер шрифта </param>/// <param name="textAlignmentOption"> выравнивание текста </param>private void ChangeTMPSettings(TextMeshProUGUI textMeshPro, int fontSizeMin, int fontSizeMax, TextAlignmentOptions textAlignmentOption = TextAlignmentOptions.Center){    // замена стандартного шрифта    textMeshPro.font = (TMP_FontAsset) AssetDatabase.LoadAssetAtPath("Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF - Fallback.asset", typeof(TMP_FontAsset));    textMeshPro.enableAutoSizing = true;    textMeshPro.fontSizeMin = fontSizeMin;    textMeshPro.fontSizeMax = fontSizeMax;    textMeshPro.alignment = textAlignmentOption;}/// <summary>/// Изменение параметров RectTransform/// </summary>/// <param name="rectTransform"> изменяемый элемент </param>/// <param name="alignment"> выравнивание </param>/// <param name="position"> позиция в 3D-пространстве </param>/// <param name="size"> размер </param>private void ChangeRectTransformSettings(RectTransform rectTransform, AnchorPresets alignment, Vector3 position, Vector2 size){    rectTransform.anchoredPosition3D = position;    rectTransform.sizeDelta = size;    rectTransform.SetAnchor(alignment);}

Замечу, что для RectTransform я использую расширение самого класса, найденное когда-то давно на форумах по Unity. С его помощью очень удобно настраивать Anchor и Pivot. Такие расширения рекомендуется складывать в папку Utils:

Пример возможной иерархии для расширений стандартных классов Пример возможной иерархии для расширений стандартных классов

Код данного расширения оставляю для вас в спойлере:

RectTransformExtension.cs
using UnityEngine;public enum AnchorPresets{    TopLeft,    TopCenter,    TopRight,    MiddleLeft,    MiddleCenter,    MiddleRight,    BottomLeft,    BottomCenter,    BottomRight,    VertStretchLeft,    VertStretchRight,    VertStretchCenter,    HorStretchTop,    HorStretchMiddle,    HorStretchBottom,    StretchAll}public enum PivotPresets{    TopLeft,    TopCenter,    TopRight,    MiddleLeft,    MiddleCenter,    MiddleRight,    BottomLeft,    BottomCenter,    BottomRight,}/// <summary>/// Расширение возможностей работы с RectTransform/// </summary>public static class RectTransformExtension{    /// <summary>    /// Изменение якоря    /// </summary>    /// <param name="source"> компонент, свойства которого требуется изменить </param>    /// <param name="align"> способ выравнивания </param>    /// <param name="offsetX"> смещение по оси X </param>    /// <param name="offsetY"> смещение по оси Y </param>    public static void SetAnchor(this RectTransform source, AnchorPresets align, int offsetX = 0, int offsetY = 0)    {        source.anchoredPosition = new Vector3(offsetX, offsetY, 0);        switch (align)        {            case (AnchorPresets.TopLeft):            {                source.anchorMin = new Vector2(0, 1);                source.anchorMax = new Vector2(0, 1);                break;            }            case (AnchorPresets.TopCenter):            {                source.anchorMin = new Vector2(0.5f, 1);                source.anchorMax = new Vector2(0.5f, 1);                break;            }            case (AnchorPresets.TopRight):            {                source.anchorMin = new Vector2(1, 1);                source.anchorMax = new Vector2(1, 1);                break;            }            case (AnchorPresets.MiddleLeft):            {                source.anchorMin = new Vector2(0, 0.5f);                source.anchorMax = new Vector2(0, 0.5f);                break;            }            case (AnchorPresets.MiddleCenter):            {                source.anchorMin = new Vector2(0.5f, 0.5f);                source.anchorMax = new Vector2(0.5f, 0.5f);                break;            }            case (AnchorPresets.MiddleRight):            {                source.anchorMin = new Vector2(1, 0.5f);                source.anchorMax = new Vector2(1, 0.5f);                break;            }            case (AnchorPresets.BottomLeft):            {                source.anchorMin = new Vector2(0, 0);                source.anchorMax = new Vector2(0, 0);                break;            }            case (AnchorPresets.BottomCenter):            {                source.anchorMin = new Vector2(0.5f, 0);                source.anchorMax = new Vector2(0.5f, 0);                break;            }            case (AnchorPresets.BottomRight):            {                source.anchorMin = new Vector2(1, 0);                source.anchorMax = new Vector2(1, 0);                break;            }            case (AnchorPresets.HorStretchTop):            {                source.anchorMin = new Vector2(0, 1);                source.anchorMax = new Vector2(1, 1);                break;            }            case (AnchorPresets.HorStretchMiddle):            {                source.anchorMin = new Vector2(0, 0.5f);                source.anchorMax = new Vector2(1, 0.5f);                break;            }            case (AnchorPresets.HorStretchBottom):            {                source.anchorMin = new Vector2(0, 0);                source.anchorMax = new Vector2(1, 0);                break;            }            case (AnchorPresets.VertStretchLeft):            {                source.anchorMin = new Vector2(0, 0);                source.anchorMax = new Vector2(0, 1);                break;            }            case (AnchorPresets.VertStretchCenter):            {                source.anchorMin = new Vector2(0.5f, 0);                source.anchorMax = new Vector2(0.5f, 1);                break;            }            case (AnchorPresets.VertStretchRight):            {                source.anchorMin = new Vector2(1, 0);                source.anchorMax = new Vector2(1, 1);                break;            }            case (AnchorPresets.StretchAll):            {                source.anchorMin = new Vector2(0, 0);                source.anchorMax = new Vector2(1, 1);                break;            }        }    }    /// <summary>    /// Изменение pivot    /// </summary>    /// <param name="source"> компонент, свойства которого требуется изменить </param>    /// <param name="preset"> способ выравнивания </param>    public static void SetPivot(this RectTransform source, PivotPresets preset)    {        switch (preset)        {            case (PivotPresets.TopLeft):            {                source.pivot = new Vector2(0, 1);                break;            }            case (PivotPresets.TopCenter):            {                source.pivot = new Vector2(0.5f, 1);                break;            }            case (PivotPresets.TopRight):            {                source.pivot = new Vector2(1, 1);                break;            }            case (PivotPresets.MiddleLeft):            {                source.pivot = new Vector2(0, 0.5f);                break;            }            case (PivotPresets.MiddleCenter):            {                source.pivot = new Vector2(0.5f, 0.5f);                break;            }            case (PivotPresets.MiddleRight):            {                source.pivot = new Vector2(1, 0.5f);                break;            }            case (PivotPresets.BottomLeft):            {                source.pivot = new Vector2(0, 0);                break;            }            case (PivotPresets.BottomCenter):            {                source.pivot = new Vector2(0.5f, 0);                break;            }            case (PivotPresets.BottomRight):            {                source.pivot = new Vector2(1, 0);                break;            }        }    }}

Использовать данные функции можно так:

// изменение настроек отображения Canvasvar canvas = GameObject.Find("Canvas");ChangeCanvasSettings(canvas, RenderMode.ScreenSpaceOverlay, CanvasScaler.ScaleMode.ScaleWithScreenSize);// изменение настроек шрифтаvar tmp = canvas.GetComponentInChildren<TextMeshProUGUI>();ChangeTMPSettings(tmp, 36, 72, TextAlignmentOptions.BottomRight);// изменение RectTransformChangeRectTransformSettings(tmp.GetComponent<RectTransform>(), AnchorPresets.MiddleCenter, Vector3.zero, new Vector2(100f, 20f));

Аналогично, может пригодиться расширение для класса Transform для поиска дочернего элемента (при наличии сложной иерархии):

TransformExtension.cs
using UnityEngine;/// <summary>/// Расширение возможностей работы с Transform/// </summary>public static class TransformExtension{    /// <summary>    /// Рекурсивный поиск дочернего элемента с определённым именем    /// </summary>    /// <param name="parent"> родительский элемент </param>    /// <param name="childName"> название искомого дочернего элемента </param>    /// <returns> null - если элемент не найден,    ///           Transform элемента, если элемент найден    /// </returns>    public static Transform FindChildWithName(this Transform parent, string childName)    {        foreach (Transform child in parent)        {            if (child.name == childName)                return child;            var result = child.FindChildWithName(childName);            if (result)                return result;        }        return null;    }}

Для тех, кому хочется иметь возможность видеть событие OnClick() на кнопке в инспекторе - может быть полезна вот такая функция:

/// <summary>/// Добавление обработчика события на кнопку (чтобы было видно в инспекторе)/// </summary>/// <param name="uiButton"> кнопка </param>/// <param name="action"> требуемое действие </param>private static void AddPersistentListenerToButton(Button uiButton, UnityAction action){    try    {        // сработает, если уже есть пустое событие        if (uiButton.onClick.GetPersistentTarget(0) == null)            UnityEventTools.RegisterPersistentListener(uiButton.onClick, 0, action);    }    catch (ArgumentException)    {        UnityEventTools.AddPersistentListener(uiButton.onClick, action);    }}

То есть, если написать следующее:

// добавление события на кнопкуAddPersistentListenerToButton(canvas.GetComponentInChildren<Button>(), FindObjectOfType<SampleClass>().QuitApp);

То результат работы в движке будет таким:

Результат работы AddPersistentListenerРезультат работы AddPersistentListener

Добавление новых объектов и изменение иерархии на сцене

Для тех, кому важно поддерживать порядок на сцене, могут быть полезны функции изменения слоя объекта, а также создания префаба на сцене с возможностью присоединения его к родительскому элементу и установке в определённом месте иерархии:

/// <summary>/// Изменение слоя объекта по названию слоя/// </summary>/// <param name="gameObject"> объект </param>/// <param name="layerName"> название слоя </param>private void ChangeObjectLayer(GameObject gameObject, string layerName){    gameObject.layer = LayerMask.NameToLayer(layerName);}/// <summary>/// Добавление префаба на сцену с возможностью определения родительского элемента и порядка в иерархии/// </summary>/// <param name="prefabPath"> путь к префабу </param>/// <param name="parentGameObject"> родительский объект </param>/// <param name="hierarchyIndex"> порядок в иерархии родительского элемента </param>private void InstantiateNewGameObject(string prefabPath, GameObject parentGameObject, int hierarchyIndex = 0){    if (parentGameObject)    {        var newGameObject = Instantiate((GameObject) AssetDatabase.LoadAssetAtPath(prefabPath, typeof(GameObject)), parentGameObject.transform);                    // изменение порядка в иерархии сцены внутри родительского элемента        newGameObject.transform.SetSiblingIndex(hierarchyIndex);    }    else        Instantiate((GameObject) AssetDatabase.LoadAssetAtPath(prefabPath, typeof(GameObject)));}

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

// изменение тэга и слоя объектаvar cube = GameObject.Find("Cube");cube.tag = "Player";ChangeObjectLayer(cube, "MainLayer");               // создание нового объекта на сцене и добавление его в иерархию к существующемуInstantiateNewGameObject("Assets/Prefabs/Capsule.prefab", cube, 1);

Элемент встанет не в конец иерархии, а на заданное место:

Цикл обновления сцен

И наконец, самое главное - функция, с помощью которой происходит вся дальнейшая автоматизация открывания-изменения-сохранения сцен, добавленных в File ->Build Settings:

/// <summary>/// Запускает цикл обновления сцен в Build Settings/// </summary>/// <param name="onSceneLoaded"> действие при открытии сцены </param>private void RunSceneUpdateCycle(UnityAction onSceneLoaded){    // получение путей к сценам для дальнейшего открытия    var scenes = EditorBuildSettings.scenes.Select(scene => scene.path).ToList();    foreach (var scene in scenes)    {        // открытие сцены        EditorSceneManager.OpenScene(scene);                    // пометка для сохранения, что на сцене были произведены изменения        EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());                    // проведение изменений        onSceneLoaded?.Invoke();                    // сохранение        EditorApplication.SaveScene();                    Debug.Log($"UPDATED {scene}");    }}

А теперь соединим всё вместе, чтобы запускать цикл обновления сцен по клику на кнопку:

Полный код SceneUpdater.cs
#if UNITY_EDITORusing System;using UnityEditor.Events;using TMPro;using UnityEngine.UI;using System.Collections.Generic;using UnityEngine.SceneManagement;using UnityEditor;using UnityEditor.SceneManagement;using System.Linq;using UnityEngine;using UnityEngine.Events;/// <summary>/// Класс для обновления сцен, включённых в список BuildSettings (активные и неактивные)/// </summary>public class SceneUpdater : EditorWindow{    [MenuItem("Custom Tools/Scene Updater")]    public static void ShowWindow()    {        GetWindow(typeof(SceneUpdater));    }    private void OnGUI()    {        // пример использования        if (GUILayout.Button("Update scenes"))            RunSceneUpdateCycle((() =>            {                // изменение тэга и слоя объекта                var cube = GameObject.Find("Cube");                cube.tag = "Player";                ChangeObjectLayer(cube, "MainLayer");                                // добавление компонента к объекту с уникальным названием                AddComponentToObject<BoxCollider>("Plane");                                // удаление объекта с уникальным названием                DestroyObjectWithName("Sphere");                                // создание нового объекта на сцене и добавление его в иерархию к существующему                InstantiateNewGameObject("Assets/Prefabs/Capsule.prefab", cube, 1);                // изменение настроек отображения Canvas                var canvas = GameObject.Find("Canvas");                ChangeCanvasSettings(canvas, RenderMode.ScreenSpaceOverlay, CanvasScaler.ScaleMode.ScaleWithScreenSize);                // изменение настроек шрифта                var tmp = canvas.GetComponentInChildren<TextMeshProUGUI>();                ChangeTMPSettings(tmp, 36, 72, TextAlignmentOptions.BottomRight);                // изменение RectTransform                ChangeRectTransformSettings(tmp.GetComponent<RectTransform>(), AnchorPresets.MiddleCenter, Vector3.zero, new Vector2(100f, 20f));                                // добавление события на кнопку                AddPersistentListenerToButton(canvas.GetComponentInChildren<Button>(), FindObjectOfType<SampleClass>().QuitApp);                // копирование настроек компонента                CopyTransformPositionRotationScale(GameObject.Find("Plane"), cube, copyScale:false);            }));    }    /// <summary>    /// Запускает цикл обновления сцен в Build Settings    /// </summary>    /// <param name="onSceneLoaded"> действие при открытии сцены </param>    private void RunSceneUpdateCycle(UnityAction onSceneLoaded)    {        // получение путей к сценам для дальнейшего открытия        var scenes = EditorBuildSettings.scenes.Select(scene => scene.path).ToList();        foreach (var scene in scenes)        {            // открытие сцены            EditorSceneManager.OpenScene(scene);                        // пометка для сохранения, что на сцене были произведены изменения            EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());                        // проведение изменений            onSceneLoaded?.Invoke();                        // сохранение            EditorApplication.SaveScene();                        Debug.Log($"UPDATED {scene}");        }    }    /// <summary>    /// Добавление обработчика события на кнопку (чтобы было видно в инспекторе)    /// </summary>    /// <param name="uiButton"> кнопка </param>    /// <param name="action"> требуемое действие </param>    private static void AddPersistentListenerToButton(Button uiButton, UnityAction action)    {        try        {            // сработает, если уже есть пустое событие            if (uiButton.onClick.GetPersistentTarget(0) == null)                UnityEventTools.RegisterPersistentListener(uiButton.onClick, 0, action);        }        catch (ArgumentException)        {            UnityEventTools.AddPersistentListener(uiButton.onClick, action);        }    }    /// <summary>    /// Изменение параметров RectTransform    /// </summary>    /// <param name="rectTransform"> изменяемый элемент </param>    /// <param name="alignment"> выравнивание </param>    /// <param name="position"> позиция в 3D-пространстве </param>    /// <param name="size"> размер </param>    private void ChangeRectTransformSettings(RectTransform rectTransform, AnchorPresets alignment, Vector3 position, Vector2 size)    {        rectTransform.anchoredPosition3D = position;        rectTransform.sizeDelta = size;        rectTransform.SetAnchor(alignment);    }    /// <summary>    /// Изменение настроек для TextMeshPro    /// </summary>    /// <param name="textMeshPro"> тестовый элемент </param>    /// <param name="fontSizeMin"> минимальный размер шрифта </param>    /// <param name="fontSizeMax"> максимальный размер шрифта </param>    /// <param name="textAlignmentOption"> выравнивание текста </param>    private void ChangeTMPSettings(TextMeshProUGUI textMeshPro, int fontSizeMin, int fontSizeMax, TextAlignmentOptions textAlignmentOption = TextAlignmentOptions.Center)    {        // замена стандартного шрифта        textMeshPro.font = (TMP_FontAsset) AssetDatabase.LoadAssetAtPath("Assets/TextMesh Pro/Resources/Fonts & Materials/LiberationSans SDF - Fallback.asset", typeof(TMP_FontAsset));        textMeshPro.enableAutoSizing = true;        textMeshPro.fontSizeMin = fontSizeMin;        textMeshPro.fontSizeMax = fontSizeMax;        textMeshPro.alignment = textAlignmentOption;    }    /// <summary>    /// Изменение отображения Canvas    /// </summary>    /// <param name="canvasGameObject"> объект, в компонентам которого будет производиться обращение </param>    /// <param name="renderMode"> способ отображения </param>    /// <param name="scaleMode"> способ изменения масштаба </param>    private void ChangeCanvasSettings(GameObject canvasGameObject, RenderMode renderMode, CanvasScaler.ScaleMode scaleMode)    {        canvasGameObject.GetComponentInChildren<Canvas>().renderMode = renderMode;        var canvasScaler = canvasGameObject.GetComponentInChildren<CanvasScaler>();        canvasScaler.uiScaleMode = scaleMode;        // выставление стандартного разрешения        if (scaleMode == CanvasScaler.ScaleMode.ScaleWithScreenSize)        {            canvasScaler.referenceResolution = new Vector2(720f, 1280f);            canvasScaler.matchWidthOrHeight = 1f;        }    }         /// <summary>    /// Получение всех верхних дочерних элементов    /// </summary>    /// <param name="parentGameObject"> родительский элемент </param>    /// <returns> список дочерних элементов </returns>    private static List<GameObject> GetAllChildren(GameObject parentGameObject)    {        var children = new List<GameObject>();                for (int i = 0; i< parentGameObject.transform.childCount; i++)            children.Add(parentGameObject.transform.GetChild(i).gameObject);                return children;    }    /// <summary>    /// Копирование позиции, поворота и размера с компонента Transform у одного объекта    /// на такой же компонент другого объекта.    /// Для корректного переноса координат у parent root объеков должны быть нулевые координаты    /// </summary>    /// <param name="objectToCopyFrom"> объект, с которого копируются части компонента </param>    /// <param name="objectToPasteTo"> объект, на который вставляются части компонента </param>    /// <param name="copyPosition"> по умолчанию позиция копируется, с помощью данного параметра это можно отключить </param>    /// <param name="copyRotation"> по умолчанию поворот копируется, с помощью данного параметра это можно отключить </param>    /// <param name="copyScale"> по умолчанию размер копируется, с помощью данного параметра это можно отключить </param>    private static void CopyTransformPositionRotationScale(GameObject objectToCopyFrom, GameObject objectToPasteTo,         bool copyPosition = true, bool copyRotation = true, bool copyScale = true)    {        var newTransform = objectToCopyFrom.GetComponent<Transform>();        var currentTransform = objectToPasteTo.GetComponent<Transform>();                if (copyPosition) currentTransform.localPosition = newTransform.localPosition;        if (copyRotation) currentTransform.localRotation = newTransform.localRotation;        if (copyScale) currentTransform.localScale = newTransform.localScale;    }        /// <summary>    /// Копирование позиции, поворота и размера с компонента RectTransform у UI-панели одного объекта    /// на такой же компонент другого объекта. Не копируется размер самой панели (для этого использовать sizeDelta)    /// Для корректного переноса координат у parent root объеков должны быть нулевые координаты    /// </summary>    /// <param name="objectToCopyFrom"> объект, с которого копируются части компонента </param>    /// <param name="objectToPasteTo"> объект, на который вставляются части компонента </param>    /// <param name="copyPosition"> по умолчанию позиция копируется, с помощью данного параметра это можно отключить </param>    /// <param name="copyRotation"> по умолчанию поворот копируется, с помощью данного параметра это можно отключить </param>    /// <param name="copyScale"> по умолчанию размер копируется, с помощью данного параметра это можно отключить </param>    private static void CopyRectTransformPositionRotationScale(GameObject objectToCopyFrom, GameObject objectToPasteTo,        bool copyPosition = true, bool copyRotation = true, bool copyScale = true)    {        var newTransform = objectToCopyFrom.GetComponent<RectTransform>();        var currentTransform = objectToPasteTo.GetComponent<RectTransform>();                if (copyPosition) currentTransform.localPosition = newTransform.localPosition;        if (copyRotation) currentTransform.localRotation = newTransform.localRotation;        if (copyScale) currentTransform.localScale = newTransform.localScale;    }    /// <summary>    /// Уничтожение объекта с уникальным названием    /// </summary>    /// <param name="objectName"> название объекта </param>    private void DestroyObjectWithName(string objectName)    {        DestroyImmediate(GameObject.Find(objectName)?.gameObject);    }    /// <summary>    /// Добавление компонента к объекту с уникальным названием    /// </summary>    /// <param name="objectName"> название объекта </param>    /// <typeparam name="T"> тип компонента </typeparam>    private void AddComponentToObject<T>(string objectName) where T : Component    {        GameObject.Find(objectName)?.gameObject.AddComponent<T>();    }    /// <summary>    /// Изменение слоя объекта по названию слоя    /// </summary>    /// <param name="gameObject"> объект </param>    /// <param name="layerName"> название слоя </param>    private void ChangeObjectLayer(GameObject gameObject, string layerName)    {        gameObject.layer = LayerMask.NameToLayer(layerName);    }    /// <summary>    /// Добавление префаба на сцену с возможностью определения родительского элемента и порядка в иерархии    /// </summary>    /// <param name="prefabPath"> путь к префабу </param>    /// <param name="parentGameObject"> родительский объект </param>    /// <param name="hierarchyIndex"> порядок в иерархии родительского элемента </param>    private void InstantiateNewGameObject(string prefabPath, GameObject parentGameObject, int hierarchyIndex = 0)    {        if (parentGameObject)        {            var newGameObject = Instantiate((GameObject) AssetDatabase.LoadAssetAtPath(prefabPath, typeof(GameObject)), parentGameObject.transform);                        // изменение порядка в иерархии сцены внутри родительского элемента            newGameObject.transform.SetSiblingIndex(hierarchyIndex);        }        else            Instantiate((GameObject) AssetDatabase.LoadAssetAtPath(prefabPath, typeof(GameObject)));    }}#endif

Таким образом, всего за один клик все сцены нашего проекта автоматически обновятся, а мы тем временем сможем пойти выпить кофе и просто понаслаждаться проделанной работой:

Волшебная кнопкаВолшебная кнопка

Заключение

Имея в своём распоряжении такой инструмент, вы сможете делать всё, что вам угодно за считанные клики: сериализовать поля, менять иерархию на сценах, настраивать Fuse/IClone/DAZ и других персонажей, а также менять Build Pipeline, но об этом как-нибудь в другой раз.

Главное, не забывайте использовать систему контроля версий и проверять запуск ваших модификаций сперва на одной сцене (т.е. без использования RunSceneUpdateCycle).

Запустить тестовый проект и получить полный код можно на моём GitHub.

Кстати, тех, кто планирует строить карьеру в IT, ябуду рада видеть на своёмYouTube-канале IT DIVA. Там вы сможете найти видео по тому, как оформлять GitHub, проходить собеседования, получать повышение, справляться с профессиональным выгоранием, управлять разработкой и т.д.

Спасибо за внимание и до новых встреч!

Подробнее..

Powershell настоящий язык программирования. Скрипт оптимизации рутины в техподдержке

20.06.2021 14:08:21 | Автор: admin

Работая в компании IT-аутсорса в качестве руководителя 3 линии поддержки, задумался, как автоматизировать подключение сотрудников по RDP, через VPN к серверам десятков клиентов.

Таблички с адресами, паролями и прочими настройками серверов, конечно, хорошо, но поиск клиента и вбивание адресов с аккаунтами занимает довольно существенное время.
Держать все подключения к VPN в Windows не самая лучшая идея, да и при переустановке оного, создавать VPNы тоже не доставляет удовольствие.
Плюс к тому, в большинстве случаев, требуется установить VPN подключение к клиенту без использования шлюза. дабы не гонять весь интернет-трафик через клиента.
Задача, к тому же, осложняется тем, что у некоторых клиентов pptp, у кого-то l2tp, у некоторых несколько подсетей, туннели и т.п.

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

До написания этого скрипта-приложения программированием не занимался вообще, разве что лет 20 назад что-то пописывал на VBS в MS Excel и MS Access, поэтому не гарантирую красивость кода и принимаю критику от опытных программистов, как можно было бы сделать красивее.

В Powershell, начиная с Windows 8 и, конечно в Windows 10, появилась прекрасная возможность создавать VPN подключения командой Add-VpnConnection и указывать какие маршруты использовать с этими соединениями командой Add-VpnConnectionRoute, для использования VPN без шлюза.

На основании этих команд и создано данное приложение. Но, обо всем по порядку.

Для начала, создаем в Google Disk таблицу с именованными столбцами:
Number; Name; VPNname; ServerAddress; RemoteNetwork; VPNLogin; VPNPass; VPNType; l2tpPsk; RDPcomp; RDPuser; RDPpass; DefaultGateway; PortWinbox; WinboxLogin; WinboxPwd; Link; Inform

  • VPNname произвольное имя для VPN соединения

  • ServerAddress адрес VPN сервера

  • RemoteNetwork адреса подсети или подсетей клиента, разделенные ;

  • VPNLogin; VPNPass учетная запись VPN

  • VPNType -тип VPN (пока используется pptp или l2tp)

  • l2tpPsk PSK для l2tp, в случае pptp оставляем пустым

  • RDPcomp адрес сервера RPD

  • RDPuser; RDPpass учетная запись RPD

  • DefaultGateway принимает значение TRUE или FALSE и указывает на то, использовать ли Шлюз по умолчанию для этого соединения. В 90% случаев = FALSE

  • PortWinbox; WinboxLogin; WinboxPwd порт, логин и пароль для Winbox, поскольку у нас большинство клиентов использует Mikrotik)

  • Link ссылка на расширенную информацию о компании, например, на диске Google, или в любом другом месте, будет выводиться в информационном поле для быстрого доступа к нужной информации

Inform примечание

Пример таблицы доступен по ссылке

Number

Name

VPNname

ServerAddress

RemoteNetwork

VPNLogin

VPNPass

VPNType

l2tpPsk

RDPcomp

RDPuser

RDPpass

DefaultGateway

PortWinbox

WinboxLogin

WinboxPwd

Link

Inform

1

Тест1

Test1

a.b.c.d

192.168.10.0/24: 10.10.0.0/24

vpnuser

passWord

pptp

none

192.168.10.1

user

passWord

TRUE

8291

Admin

Admin

http://yandex.ru

тест

2

Тест2

Test2

e.f.j.k

192.168.2.0/24

vpnuser

passWord

l2tp

KdoSDtdP

192.168.2.1

user

passWord

FALSE

8291

Admin

Admin

Скриншот работающего приложения с затертыми данными:

Далее следует листинг приложения с комментариями и пояснениями. Если интересно, но непонятно, задавайте вопросы, постараюсь прокомментировать

function Get-Clients #Функция принимает строку адреса файла в Google Drive и возвращает в виде массива данных о клиентах{param([string]$google_url = "")[string]$xlsFile = $google_url$csvFile = "$env:temp\clients.csv"$Comma = ','Invoke-WebRequest $xlsFile -OutFile $csvFile$clients = Import-Csv -Delimiter $Comma -Path "$env:temp\clients.csv"Remove-Item -Path $csvFilereturn $clients}function Main {<#    Функция, срабатываемая при запуске скрипта#>Param ([String]$Commandline)#Иннициализируем переменные и присваиваем начальные значения. Здесь же, указываем путь к таблице с клиентами$Global:Clients = $null$Global:Current$Global:CurrentRDPcomp$Global:google_file = "https://docs.google.com/spreadsheets/d/1O-W1YCM4x3o5W1w6XahCJZpkTWs8cREXVF69gs1dD0U/export?format=csv" # Таблица скачивается сразу в виде csv-файла$Global:Clients = Get-Clients ($Global:google_file) # Присваиваем значения из таблицы массиву #Скачиваем Winbox64 во временную папку$download_url = "https://download.mikrotik.com/winbox/3.27/winbox64.exe"$Global:local_path = "$env:temp\winbox64.exe"If ((Test-Path $Global:local_path) -ne $true){$WebClient = New-Object System.Net.WebClient$WebClient.DownloadFile($download_url, $Global:local_path)}  #Разрываем все текущие VPN соединения (на всякий случай)foreach ($item in get-vpnconnection | where { $_.ConnectionStatus -eq "Connected" }){Rasdial $item.Name /disconnect}  #Удаляем все, ранее созданные программой временные соединения, если вдруг не удалились при некорректном закрытии приложенияget-vpnconnection | where { $_.Name -match "tmp" } | Remove-VpnConnection -Force#Запускаем приложениеShow-MainForm_psf}#Собственно, само приложениеfunction Show-MainForm_psf{[void][reflection.assembly]::Load('System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089')[void][reflection.assembly]::Load('System.Drawing, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a')#Создаем форму и объекты формы[System.Windows.Forms.Application]::EnableVisualStyles()$formКлиентыАльбус = New-Object 'System.Windows.Forms.Form'$statusbar1 = New-Object 'System.Windows.Forms.StatusBar'$groupboxTools = New-Object 'System.Windows.Forms.GroupBox'$buttonPing = New-Object 'System.Windows.Forms.Button'$buttonВыход = New-Object 'System.Windows.Forms.Button'$buttonWindox = New-Object 'System.Windows.Forms.Button'$buttonПеречитатьДанные = New-Object 'System.Windows.Forms.Button'$buttonPingAll = New-Object 'System.Windows.Forms.Button'$groupboxRDP = New-Object 'System.Windows.Forms.GroupBox'$comboboxRDP = New-Object 'System.Windows.Forms.ComboBox'$textboxRDPLogin = New-Object 'System.Windows.Forms.TextBox'$textboxRdpPwd = New-Object 'System.Windows.Forms.TextBox'$buttonПодключитьRDP = New-Object 'System.Windows.Forms.Button'$groupboxVPN = New-Object 'System.Windows.Forms.GroupBox'$buttonПодключитьVPN = New-Object 'System.Windows.Forms.Button'$buttonОтключитьVPN = New-Object 'System.Windows.Forms.Button'$checkboxШлюзПоумолчанию = New-Object 'System.Windows.Forms.CheckBox'$richtextboxinfo = New-Object 'System.Windows.Forms.RichTextBox'$listbox_clients = New-Object 'System.Windows.Forms.ListBox'$InitialFormWindowState = New-Object 'System.Windows.Forms.FormWindowState'  #----------------------------------------------# Обработчики событий#----------------------------------------------$formКлиентыАльбус_Load = {#При загрузке формы очистить поле информации и заполнить поле с клиентами (их названиями) $richtextboxinfo.Clear()$Global:Clients | ForEach-Object {[void]$listbox_clients.Items.Add($_.Name)} # В листбокс добавляем всех наших клиентов по именам и массива при загрузке формы}$listbox_clients_SelectedIndexChanged = {#Прочитать из массива информацию о клиенте при выборе его в поле listbox_clients (массив, как мы помним считан из файла с диска Google)$statusbar1.Text = 'Выбран клиент: ' + $listbox_clients.SelectedItem.ToString() # Пишем клиента в статусбар$Global:Current = $Global:Clients.Where({ $_.Name -eq $listbox_clients.SelectedItem.ToString() })If ($Current.PortWinbox -ne 0) # Если порт Winbox указан, то у клиента Mikrotik, включаем соответствующую кнопку{$buttonWindox.Enabled = $true$buttonWindox.Text = "Winbox"}$VPNname = $Global:Current.VPNname + "-tmp" #Добавляем к имени VPN соединения "-tmp" для указания метки временного соединения, чтобы при выходе удалить только ихswitch ($Global:Current.VPNType) #В зависимости от типа VPN пишем на кнопке "Подключить pptp VPN" или "Подключить l2tp VPN", если у клиента нет VPN, то пишем "Здесь нет VPN"{"pptp" {$buttonПодключитьVPN.Enabled = $true$buttonПодключитьVPN.Text = "Подключить pptp VPN"}"l2tp" {$buttonПодключитьVPN.Enabled = $true$buttonПодключитьVPN.Text = "Подключить l2tp VPN"}DEFAULT{$buttonПодключитьVPN.Enabled = $false$buttonПодключитьVPN.Text = "Здесь нет VPN"}}switch ($Global:Current.DefaultGateway) #Смотрим в массиве, используется ли у клиента "Шлюз по-умолчанию" и заполняем соответствующий чекбокс{"FALSE"{ $checkboxШлюзПоумолчанию.Checked = $false }"Нет"{ $checkboxШлюзПоумолчанию.Checked = $false }"TRUE"{ $checkboxШлюзПоумолчанию.Checked = $true }"Да"{ $checkboxШлюзПоумолчанию.Checked = $true }DEFAULT{ $checkboxШлюзПоумолчанию.Checked = $false }}$VPNStatus = (ipconfig | Select-String $VPNname -Quiet) #Проверяем, не установлено ли уже это VPN соединение?If ($VPNStatus) #Если установлено, то разблокируем кнопку "Подключить RDP"{$buttonПодключитьRDP.Enabled = $true}else{$buttonПодключитьRDP.Enabled = $false}$richtextboxinfo.Clear() #Очищаем информационное поле # И заполняем информацией о клиенте из массива$richtextboxinfo.SelectionColor = 'Black'$richtextboxinfo.Text = "Клиент: " + $Global:Current.Name + [System.Environment]::NewLine + `"Имя VPN: " + $Global:Current.VPNname + [System.Environment]::NewLine + `"Тип VPN: " + $Global:Current.VPNType + [System.Environment]::NewLine + `"Адрес сервера: " + $Global:Current.ServerAddress + [System.Environment]::NewLine + `"Подсеть клиента: " + $Global:Current.RemoteNetwork + [System.Environment]::NewLine + `"Адрес сервера RDP: " + $Global:Current.RDPcomp + [System.Environment]::NewLine + [System.Environment]::NewLine + `"DefaultGateway: " + $Global:Current.DefaultGateway + [System.Environment]::NewLine + [System.Environment]::NewLine + `"Примечание: " + [System.Environment]::NewLine + $Global:Current.Inform + [System.Environment]::NewLine + `"Connection '" + $VPNname + "' status is " + $buttonПодключитьRDP.Enabled + [System.Environment]::NewLine$richtextboxinfo.AppendText($Global:Current.Link)$RDPServers = $Global:Current.RDPcomp.Split(';') -replace '\s', '' #Считываем и разбираем RDP серверы клиента из строки с разделителем в массив#Добавляем из в выпадающее поле выбора сервера$comboboxRDP.Items.Clear()$comboboxRDP.Text = $RDPServers[0]foreach ($RDPServer in $RDPServers){$comboboxRDP.Items.Add($RDPServer)}#Заполняем поля имени и пароля RDP по умолчанию из таблицы о клиенте (при желании, их можно поменять в окне программы)$textboxRdpPwd.Text = $Global:Current.RDPpass$textboxRdpLogin.Text = $Global:Current.RDPuser} # Форма заполнена, при смене выбранного клиента произойдет перезаполнение полей в соответствии с выбранным клиентом$buttonWindox_Click = {#Обработка нажатия кнопки WinboxIf ($Global:Current.PortWinbox -ne 0) #Если порт Winbox заполнен, то открываем скачанный ранее Winbox, подставляем туда имя и пароль к нему и запускаем{$runwinbox = "$env:temp\winbox64.exe"$ServerPort = $Global:Current.ServerAddress + ":" + $Global:Current.PortWinbox$ServerLogin = " """ + $Global:Current.WinboxLogin + """"$ServerPass = " """ + $Global:Current.WinboxPwd + """"$Arg = "$ServerPort $ServerLogin $ServerPass "Start-Process -filePath $runwinbox -ArgumentList $Arg}}$buttonПодключитьVPN_Click = {#Обработка нажатия кнопки ПодключитьVPN$VPNname = $Global:Current.VPNname + "-tmp" #Добавляем к имени VPN соединения "-tmp" для указания метки временного соединения, чтобы при выходе удалить только их$richtextboxinfo.Clear() #Очищаем информационное поля для вывода туда информации о процессе подключения$richtextboxinfo.Text = "Клиент: " + $Global:Current.Name + [System.Environment]::NewLineforeach ($item in get-vpnconnection | where { $_.ConnectionStatus -eq "Connected" }) #Разрываем все установленные соединения{$richtextboxinfo.Text = $richtextboxinfo.Text + "Обнаружено активное соединение " + $item.Name + " разрываем его" + [System.Environment]::NewLineRasdial $item.Name /disconnect}Remove-VpnConnection $VPNname -Force #Удаляем соединение, если ранее оно было создано$RemoteNetworks = $Global:Current.RemoteNetwork.Split(';') -replace '\s', '' #Считываем и разбираем по строкам в массив список подсетей клиента разделенный ;switch ($Global:Current.VPNType) #В зависимости от типа VPNа создаем pptp или l2tp соединение{"pptp" {$richtextboxinfo.Text = $richtextboxinfo.Text + "Создаем pptp подключение " + $VPNname + [System.Environment]::NewLineIf ($checkboxШлюзПоумолчанию.Checked -eq $false) #Если не используется "Шлюз по-умолчанию", то создаем VPN соединение без него и прописываем маршруты{$Errcon = (Add-VpnConnection -Name $VPNname -ServerAddress $Global:Current.ServerAddress -TunnelType $Global:Current.VPNType -SplitTunneling -Force -RememberCredential -PassThru) #Здесь происходит создание VPNforeach ($RemoteNetwork in $RemoteNetworks) #Добавляем все подсети клиента к этому VPN{$richtextboxinfo.AppendText('Добавляем маршрут к ' + $RemoteNetwork + [System.Environment]::NewLine)Add-VpnConnectionRoute -ConnectionName $VPNname -DestinationPrefix $RemoteNetwork -PassThru}}else #Если используется "Шлюз по-умолчанию", то создаем VPN соединение с ним и маршруты к клиенту не нужны{$Errcon = (Add-VpnConnection -Name $VPNname -ServerAddress $Global:Current.ServerAddress -TunnelType $Global:Current.VPNType -Force -RememberCredential -PassThru)}}"l2tp" {$richtextboxinfo.Text = $richtextboxinfo.Text + "Создаем l2tp подключение " + $Global:Current.VPNname + [System.Environment]::NewLineIf ($checkboxШлюзПоумолчанию.Checked -eq $false) #Если не используется "Шлюз по-умолчанию", то создаем VPN соединение без него и прописываем маршруты{$Errcon = (Add-VpnConnection -Name $VPNname -ServerAddress $Global:Current.ServerAddress -TunnelType $Global:Current.VPNType -L2tpPsk $Global:Current.l2tpPsk -SplitTunneling -Force -RememberCredential -PassThru) #Здесь происходит создание VPNforeach ($RemoteNetwork in $RemoteNetworks) #Добавляем все подсети клиента к этому VPN{$richtextboxinfo.AppendText('Добавляем маршрут к ' + $RemoteNetwork + [System.Environment]::NewLine)Add-VpnConnectionRoute -ConnectionName $VPNname -DestinationPrefix $RemoteNetwork -PassThru}}else #Если используется "Шлюз по-умолчанию", то создаем VPN соединение с ним и маршруты к клиенту не нужны{$Errcon = (Add-VpnConnection -Name $VPNname -ServerAddress $Global:Current.ServerAddress -TunnelType $Global:Current.VPNType -L2tpPsk $Global:Current.l2tpPsk -Force -RememberCredential -PassThru)}}}$richtextboxinfo.AppendText("Устанавливаем " + $Global:Current.VPNType + " подключение к " + $VPNname + [System.Environment]::NewLine)$Errcon = Rasdial $VPNname $Global:Current.VPNLogin $Global:Current.VPNPass #Устанавливаем созданное VPN подключение и выводим информацию в поле$richtextboxinfo.Text = $richtextboxinfo.Text + [System.Environment]::NewLine + $Errcon + [System.Environment]::NewLineIf ((ipconfig | Select-String $VPNname -Quiet)) #Проверяем успешность соединения и, если все удачно, разблокируем кнопку RDP  и кнопку "Отключить VPN"{$buttonПодключитьRDP.Enabled = $true$buttonОтключитьVPN.Visible = $true$buttonОтключитьVPN.Enabled = $true$statusbar1.Text = $Global:Current.Name + ' подключен'}}$formКлиентыАльбус_FormClosing = [System.Windows.Forms.FormClosingEventHandler]{#При закрытии формы подчищаем за собой. Разрываем и удаляем все созданные соединения. foreach ($item in get-vpnconnection | where { $_.ConnectionStatus -eq "Connected" }){$richtextboxinfo.Text = $richtextboxinfo.Text + "Обнаружено активное соединение " + $item.Name + " разрываем его" + [System.Environment]::NewLineRasdial $item.Name /disconnect}$richtextboxinfo.Text = $richtextboxinfo.Text + "Удаляем все временные соединения" + [System.Environment]::NewLineget-vpnconnection | where { $_.Name -match "tmp" } | Remove-VpnConnection -Force#Удаляем информацию о RPD-серверах из реестра$Global:Clients | ForEach-Object {$term = "TERMSRV/" + $_.RDPcompcmdkey /delete:$term}}$buttonПодключитьRDP_Click = {#Обработка кнопки ПодключитьRDP$RDPcomp = $comboboxRDP.Text$RDPuser = $textboxRDPLogin.Text$RDPpass = $textboxRdpPwd.Textcmdkey /generic:"TERMSRV/$RDPcomp" /user:"$RDPuser" /pass:"$RDPpass"mstsc /v:$RDPcomp}$buttonОтключитьVPN_Click = {#При отключении VPN подчищаем за собой и оповещаем о процессе в поле информацииforeach ($item in get-vpnconnection | where { $_.ConnectionStatus -eq "Connected" }){$richtextboxinfo.Text = $richtextboxinfo.Text + "Обнаружено активное соединение " + $item.Name + " разрываем его" + [System.Environment]::NewLineRasdial $item.Name /disconnect}$richtextboxinfo.Text = $richtextboxinfo.Text + "Удаляем все временные соединения" + [System.Environment]::NewLineget-vpnconnection | where { $_.Name -match "tmp" } | Remove-VpnConnection -Force$buttonОтключитьVPN.Visible = $false$buttonПодключитьRDP.Enabled = $false$statusbar1.Text = $Global:Current.Name + ' отключен'}$buttonPingAll_Click={#Пингуем всех клиентов и оповещаем о результатах$I=0$richtextboxinfo.Clear()$richtextboxinfo.SelectionColor = 'Black'$clientscount = $Global:Clients.count$Global:Clients | ForEach-Object {if ((test-connection -Count 1 -computer $_.ServerAddress -quiet) -eq $True){$richtextboxinfo.SelectionColor = 'Green'$richtextboxinfo.AppendText($_.Name +' ('+ $_.ServerAddress +') доступен' + [System.Environment]::NewLine)}else{$richtextboxinfo.SelectionColor = 'Red'$richtextboxinfo.AppendText($_.Name + ' (' + $_.ServerAddress + ')  недоступен (или закрыт ICMP)' + [System.Environment]::NewLine)}$richtextboxinfo.ScrollToCaret()$I = $I + 1Write-Progress -Activity "Ping in Progress" -Status "$i clients of $clientscount pinged" -PercentComplete ($i/$clientscount*100)}$richtextboxinfo.SelectionColor = 'Black'Write-Progress -Activity "Ping in Progress" -Status "Ready" -Completed}$buttonПеречитатьДанные_Click={#Перечитываем данные из таблицы Google$Global:Clients = Get-Clients ($Global:google_file)$listbox_clients.Items.Clear()$Global:Clients | ForEach-Object {[void]$listbox_clients.Items.Add($_.Name)}}$buttonВыход_Click = {#Выход$formКлиентыАльбус.Close()}$richtextboxinfo_LinkClicked=[System.Windows.Forms.LinkClickedEventHandler]{#Обработка нажатия на ссылку в окне информацииStart-Process $_.LinkText.ToString()}$buttonPing_Click={#Пингуем ip текущего клиента и выводим результат в поле информацииif ((test-connection -Count 1 -computer $Global:Current.ServerAddress -quiet) -eq $True){$richtextboxinfo.AppendText([System.Environment]::NewLine)$richtextboxinfo.SelectionColor = 'Green'$richtextboxinfo.AppendText($Global:Current.Name + ' (' + $Global:Current.ServerAddress + ') доступен' + [System.Environment]::NewLine)}else{$richtextboxinfo.AppendText([System.Environment]::NewLine)$richtextboxinfo.SelectionColor = 'Red'$richtextboxinfo.AppendText($Global:Current.Name + ' (' + $Global:Current.ServerAddress + ')  недоступен (или закрыт ICMP)' + [System.Environment]::NewLine)}}#----------------------------------------------#Описание объектов формы#----------------------------------------------## formКлиентыАльбус#$formКлиентыАльбус.Controls.Add($statusbar1)$formКлиентыАльбус.Controls.Add($groupboxTools)$formКлиентыАльбус.Controls.Add($groupboxRDP)$formКлиентыАльбус.Controls.Add($groupboxVPN)$formКлиентыАльбус.Controls.Add($richtextboxinfo)$formКлиентыАльбус.Controls.Add($listbox_clients)$formКлиентыАльбус.AutoScaleDimensions = '6, 13'$formКлиентыАльбус.AutoScaleMode = 'Font'$formКлиентыАльбус.AutoSize = $True$formКлиентыАльбус.ClientSize = '763, 446'$formКлиентыАльбус.FormBorderStyle = 'FixedSingle'$formКлиентыАльбус.MaximizeBox = $False$formКлиентыАльбус.Name = 'formКлиентыАльбус'$formКлиентыАльбус.SizeGripStyle = 'Hide'$formКлиентыАльбус.StartPosition = 'CenterScreen'$formКлиентыАльбус.Text = 'Клиенты Альбус'$formКлиентыАльбус.add_FormClosing($formКлиентыАльбус_FormClosing)$formКлиентыАльбус.add_Load($formКлиентыАльбус_Load)## statusbar1#$statusbar1.Location = '0, 424'$statusbar1.Name = 'statusbar1'$statusbar1.Size = '763, 22'$statusbar1.TabIndex = 17## groupboxTools#$groupboxTools.Controls.Add($buttonPing)$groupboxTools.Controls.Add($buttonВыход)$groupboxTools.Controls.Add($buttonWindox)$groupboxTools.Controls.Add($buttonПеречитатьДанные)$groupboxTools.Controls.Add($buttonPingAll)$groupboxTools.Location = '308, 258'$groupboxTools.Name = 'groupboxTools'$groupboxTools.Size = '147, 163'$groupboxTools.TabIndex = 10$groupboxTools.TabStop = $False$groupboxTools.Text = 'Tools'$groupboxTools.UseCompatibleTextRendering = $True## buttonPing#$buttonPing.Location = '7, 44'$buttonPing.Name = 'buttonPing'$buttonPing.Size = '133, 23'$buttonPing.TabIndex = 12$buttonPing.Text = 'Ping'$buttonPing.UseCompatibleTextRendering = $True$buttonPing.UseVisualStyleBackColor = $True$buttonPing.add_Click($buttonPing_Click)## buttonВыход#$buttonВыход.Location = '7, 125'$buttonВыход.Name = 'buttonВыход'$buttonВыход.Size = '133, 23'$buttonВыход.TabIndex = 15$buttonВыход.Text = 'Выход'$buttonВыход.UseCompatibleTextRendering = $True$buttonВыход.UseVisualStyleBackColor = $True$buttonВыход.add_Click($buttonВыход_Click)## buttonWindox#$buttonWindox.Enabled = $False$buttonWindox.Location = '7, 17'$buttonWindox.Name = 'buttonWindox'$buttonWindox.Size = '133, 23'$buttonWindox.TabIndex = 11$buttonWindox.Text = 'Windox'$buttonWindox.UseCompatibleTextRendering = $True$buttonWindox.UseVisualStyleBackColor = $True$buttonWindox.add_Click($buttonWindox_Click)## buttonПеречитатьДанные#$buttonПеречитатьДанные.Location = '7, 98'$buttonПеречитатьДанные.Name = 'buttonПеречитатьДанные'$buttonПеречитатьДанные.Size = '133, 23'$buttonПеречитатьДанные.TabIndex = 14$buttonПеречитатьДанные.Text = 'Перечитать данные'$buttonПеречитатьДанные.UseCompatibleTextRendering = $True$buttonПеречитатьДанные.UseVisualStyleBackColor = $True$buttonПеречитатьДанные.add_Click($buttonПеречитатьДанные_Click)## buttonPingAll#$buttonPingAll.Location = '7, 71'$buttonPingAll.Name = 'buttonPingAll'$buttonPingAll.Size = '133, 23'$buttonPingAll.TabIndex = 13$buttonPingAll.Text = 'Ping All'$buttonPingAll.UseCompatibleTextRendering = $True$buttonPingAll.UseVisualStyleBackColor = $True$buttonPingAll.add_Click($buttonPingAll_Click)## groupboxRDP#$groupboxRDP.Controls.Add($comboboxRDP)$groupboxRDP.Controls.Add($textboxRDPLogin)$groupboxRDP.Controls.Add($textboxRdpPwd)$groupboxRDP.Controls.Add($buttonПодключитьRDP)$groupboxRDP.Location = '308, 128'$groupboxRDP.Name = 'groupboxRDP'$groupboxRDP.Size = '147, 126'$groupboxRDP.TabIndex = 5$groupboxRDP.TabStop = $False$groupboxRDP.Text = 'RDP'$groupboxRDP.UseCompatibleTextRendering = $True## comboboxRDP#$comboboxRDP.FormattingEnabled = $True$comboboxRDP.Location = '7, 17'$comboboxRDP.Name = 'comboboxRDP'$comboboxRDP.Size = '133, 21'$comboboxRDP.TabIndex = 6$comboboxRDP.Text = 'IP RDP сервера'## textboxRDPLogin#$textboxRDPLogin.Location = '7, 44'$textboxRDPLogin.Name = 'textboxRDPLogin'$textboxRDPLogin.Size = '133, 20'$textboxRDPLogin.TabIndex = 7$textboxRDPLogin.Text = 'RDP-login'## textboxRdpPwd#$textboxRdpPwd.Location = '7, 69'$textboxRdpPwd.Name = 'textboxRdpPwd'$textboxRdpPwd.PasswordChar = '*'$textboxRdpPwd.Size = '133, 20'$textboxRdpPwd.TabIndex = 8$textboxRdpPwd.Text = 'RDP-Password'## buttonПодключитьRDP#$buttonПодключитьRDP.Enabled = $False$buttonПодключитьRDP.Location = '7, 94'$buttonПодключитьRDP.Name = 'buttonПодключитьRDP'$buttonПодключитьRDP.Size = '133, 20'$buttonПодключитьRDP.TabIndex = 9$buttonПодключитьRDP.Text = 'Подключить RDP'$buttonПодключитьRDP.UseCompatibleTextRendering = $True$buttonПодключитьRDP.UseVisualStyleBackColor = $True$buttonПодключитьRDP.add_Click($buttonПодключитьRDP_Click)## groupboxVPN#$groupboxVPN.Controls.Add($buttonПодключитьVPN)$groupboxVPN.Controls.Add($buttonОтключитьVPN)$groupboxVPN.Controls.Add($checkboxШлюзПоумолчанию)$groupboxVPN.Location = '308, 27'$groupboxVPN.Name = 'groupboxVPN'$groupboxVPN.Size = '147, 98'$groupboxVPN.TabIndex = 1$groupboxVPN.TabStop = $False$groupboxVPN.Text = 'VPN'$groupboxVPN.UseCompatibleTextRendering = $True## buttonПодключитьVPN#$buttonПодключитьVPN.Enabled = $False$buttonПодключитьVPN.Location = '7, 45'$buttonПодключитьVPN.Name = 'buttonПодключитьVPN'$buttonПодключитьVPN.Size = '133, 20'$buttonПодключитьVPN.TabIndex = 3$buttonПодключитьVPN.Text = 'Подключить VPN'$buttonПодключитьVPN.UseCompatibleTextRendering = $True$buttonПодключитьVPN.UseVisualStyleBackColor = $True$buttonПодключитьVPN.add_Click($buttonПодключитьVPN_Click)## buttonОтключитьVPN#$buttonОтключитьVPN.Enabled = $False$buttonОтключитьVPN.Location = '7, 67'$buttonОтключитьVPN.Name = 'buttonОтключитьVPN'$buttonОтключитьVPN.Size = '133, 20'$buttonОтключитьVPN.TabIndex = 4$buttonОтключитьVPN.Text = 'Отключить VPN'$buttonОтключитьVPN.UseCompatibleTextRendering = $True$buttonОтключитьVPN.UseVisualStyleBackColor = $True$buttonОтключитьVPN.Visible = $False$buttonОтключитьVPN.add_Click($buttonОтключитьVPN_Click)## checkboxШлюзПоумолчанию#$checkboxШлюзПоумолчанию.Location = '7, 19'$checkboxШлюзПоумолчанию.Name = 'checkboxШлюзПоумолчанию'$checkboxШлюзПоумолчанию.Size = '133, 24'$checkboxШлюзПоумолчанию.TabIndex = 2$checkboxШлюзПоумолчанию.Text = 'Шлюз по-умолчанию'$checkboxШлюзПоумолчанию.TextAlign = 'MiddleRight'$checkboxШлюзПоумолчанию.UseCompatibleTextRendering = $True$checkboxШлюзПоумолчанию.UseVisualStyleBackColor = $True## richtextboxinfo#$richtextboxinfo.Cursor = 'Default'$richtextboxinfo.ForeColor = 'WindowText'$richtextboxinfo.HideSelection = $False$richtextboxinfo.Location = '461, 27'$richtextboxinfo.Name = 'richtextboxinfo'$richtextboxinfo.ReadOnly = $True$richtextboxinfo.ScrollBars = 'ForcedVertical'$richtextboxinfo.ShowSelectionMargin = $True$richtextboxinfo.Size = '290, 394'$richtextboxinfo.TabIndex = 16$richtextboxinfo.Text = ''$richtextboxinfo.add_LinkClicked($richtextboxinfo_LinkClicked)## listbox_clients#$listbox_clients.FormattingEnabled = $True$listbox_clients.Location = '12, 27'$listbox_clients.Name = 'listbox_clients'$listbox_clients.Size = '290, 394'$listbox_clients.TabIndex = 0$listbox_clients.add_SelectedIndexChanged($listbox_clients_SelectedIndexChanged)#Save the initial state of the form$InitialFormWindowState = $formКлиентыАльбус.WindowState#Init the OnLoad event to correct the initial state of the form$formКлиентыАльбус.add_Load($Form_StateCorrection_Load)#Clean up the control events$formКлиентыАльбус.add_FormClosed($Form_Cleanup_FormClosed)#Store the control values when form is closing$formКлиентыАльбус.add_Closing($Form_StoreValues_Closing)#Show the Formreturn $formКлиентыАльбус.ShowDialog()}#Запуск приложения!Main ($CommandLine) 

Скрипт можно запускать как скрипт ps1 или скомпилировать в exe через ps2exe и использовать как полноценное приложение

Подробнее..

Digital-трансформация завода CRM для ERP, роботизация БП и оживление железа, ЛК, чат-боты и dream team (ч. 2)

21.01.2021 14:15:10 | Автор: admin

Часть 1: CRM для ERP

Часть 2: Роботизация бизнес-процессов (в этой публикации)

Часть 3: Волшебные интерфейсы и оживление железа

Часть 4: Личные кабинеты, чат-боты и dream team

Роботизация бизнес-процессов на примере закупок (2 кейса)

Как часто выглядит процесс закупок на предприятии (без погружения в детали):
  1. Поиск поставщиков и запрос коммерческих предложений.

  2. Сравнение полученных предложений и выбор поставщика.

  3. Заключение договора и осуществление поставки.

Сразу возникают элементарные вопросы:

  1. Где найти поставщиков и как запросить коммерческие предложения?

  2. Как сравнить полученные коммерческие предложения и выбрать поставщика?

  3. Как заключить договор и проконтролировать поставку?

Обычно это происходит так:

  1. Найти поставщиков в справочнике ERP или погуглить, запросить предложения в почте или по телефону.

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

  3. Получить форму договора у поставщика или составить свою и ожидать поставку в срок.

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

Обычный рабочий день менеджера по закупкамОбычный рабочий день менеджера по закупкам

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

Какие подготовительные этапы были выполнены в ERP до роботизации бизнес-процессов
  1. Исправление ошибок в исторических данных справочника номенклатуры(сначала автоматически с помощью разработанных алгоритмов; затем визуальная проверка и исправление пользователями того, что не удалось однозначно сделать в автоматическом режиме):

    - исправление орфографических ошибки с помощью интеграции сЯндекс.Спеллер

    - удаление "мусорных" символов в наименованиях (спец.символы, кавычки, буквы "ё", лишние пробелы)

    - замена латинских букв в русских словах на буквы кириллицы, и наоборот

    - и другие алгоритмы исправления ошибок в наименованиях (всего 8)

    Разработанные алгоритмы исправления ошибок в наименованиях номенклатуры в ERPРазработанные алгоритмы исправления ошибок в наименованиях номенклатуры в ERP
  2. Упорядочивание данных в справочнике номенклатуры:

    - логическое группирование по видам номенклатуры

    - введение аналогов и замен

  3. Обогащение данных в справочниках контрагентов и контактных лиц:

    - разработка и заполнение портрета поставщика (частично автоматическое на основе исторических данных о поставках в ERP)

    - актуализация контактных данных контрагентов (email, телефоны)

    - актуализация данных контактных лиц контрагентов (ФИО, должность, email, мобильные телефоны)

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

Кейс #1: Роботизация процесса проверки поставщиков

На нашем заводе юридическая проверка поставщиков это обязательная процедура перед заключением договора поставки. В зависимости от категории поставщика и суммы закупки, каждый контрагент (ИП или юр. лицо) должен предоставить от 10 до 20 сканов документов.

В одном процессе участвуют 3-4 сотрудника из разных подразделений:
  • Менеджер по закупкам (ставит задачи менеджеру по документообороту, контролирует ход процесса проверки).

  • Менеджер по документообороту (запрашивает документы у контрагентов и выполняет их первичную проверку).

  • Юрист (выполняет юридическую проверку контрагента и документов).

  • Сотрудник службы безопасности (при необходимости).

ВАЖНО!Чтобы не только правильно выполнялась процедура проверки, но вовремя запрашивалась и поступала информация от поставщиков, вовремя выполнялись и передавались задачи между всем участниками процесса проверки, включая поставщиков.

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

  • ERP-система (рабочее место сотрудников).

  • Сайт компании (интеграция с ERP и сервисом DaData.ru).

  • Почта Gmail (интеграция с ERP и сайтом).

  • Сервис DaData.ru (интеграция с сайтом и ERP).

  • Сервис 1С:Контрагент (интеграция с ERP).

На сайте компании разработана форма для самостоятельного заполнения контрагентом.

Для простоты заполнения к форме сайта подключен сервис DaData.ru - это и заполнение поле из общедоступных справочников и автоматическое заполнение связанных полей.

Пример формы на сайте для заполнения поставщиком

1, 2, 3, 4 - Заполнение по справочникам из сервиса DaData.ru

5 - Список компетенций поставщиков и подрядчиков из ERP

Все формы на сайте адаптивные, можно легко заполнить на смартфоне.

Пример адаптивной формы на сайте для заполнения поставщиком
Адаптивная форма для заполнения на смартфонеАдаптивная форма для заполнения на смартфоне

Заполненная на сайте форма отправляется в ERP, где автоматически преобразуется в данные - создаются элементы справочников (контрагенты, контактные лица), документ проверки поставщика, задачи для сотрудников, письма с уведомлениями поставщиков.

Пример формы документа проверки поставщика в ERP
Пользовательский интерфейс в ERP для проверки поставщикаПользовательский интерфейс в ERP для проверки поставщика
  1. Текущее состояние проверки поставщика.

  2. Статус предквалификации (отдельный бизнес-процесс, здесь не рассматривается).

  3. Автоматическое определение статуса контрагента (действующий или в состоянии ликвидации).

  4. Досье контрагента на дату начала проверки (автоматически сохранено в PDF).

  5. Досье контрагента на дату открытия документа проверки (динамическое формирование отчета).

  6. Блок проверки документов (с возможностью автоматического запроса дополнительных документов, которые изначально не запрашиваются на сайте).

  7. Автоматическая проверка соответствия кодов ОКВЭД контрагента предмету закупки.

  8. Автоматическая проверка срока регистрации контрагента.

  9. Автоматические задачи сотрудникам, история действий по проверке, автоматические письма контрагенту.

Робот автоматически отклоняет проверку контрагентас отрицательным результатом по 10 признакам, и одновременным уведомлением по email поставщика о причине.

Проверка контрагента в ERP автоматически не пройденаПроверка контрагента в ERP автоматически не пройдена

1 - Робот автоматически устанавливает отрицательный статус проверки и завершает бизнес-процесс проверки.

2 - Робот автоматически определяет причина не прохождения проверки, и автоматически отправляет контрагенту письмо с причиной не прохождения проверки.

3 - Менеджер по закупкам всегда может обосновать проведение проверки, отправив ответственному задачу на согласование (бизнес-процесс проверки будет автоматически инициирован при положительном согласовании).

Пример автоматических признаков, которые проверяет робот проверки поставщиков
10 причин автоматического не прохождения проверки контрагента10 причин автоматического не прохождения проверки контрагента
Пример автоматического письма поставщику, когда проверка не пройдена
Автоматическое письмо поставщику от робота, что проверка не пройденаАвтоматическое письмо поставщику от робота, что проверка не пройдена

Когда у робота нет причин отклонять проверку,в ERP автоматически стартует бизнес-процесси сотрудники начинают получать автоматические задачи.

Пример задачи, которую получает сотрудник на своем рабочем месте в ERP
Автоматическая задача пользователю от робота в ERPАвтоматическая задача пользователю от робота в ERP

1 - Назначен ответственный

2 - Установлен крайний срок выполнения задачи

3 - Ссылка на документ проверки поставщика

Таким образом,робот автоматически ставит задачи разным сотрудникам(или ролям) при переходе бизнес-процесса проверки на разные этапы.

Пример истории задач сотрудникам при проверке поставщика
История автоматических задач по проверке поставщика в ERPИстория автоматических задач по проверке поставщика в ERP

Все задачи для сотрудников имеют сроки выполнения.Настройки всегда можно изменить в ERP (в пользовательском режиме).

Пример настроек робота в ERP для бизнес-процесса проверки поставщиков

1 - Настройки адресации ролей и сроков выполнения задач ответственными.

2 - Настройка служебных ролей для ожидающих процессов.

3 - Настройка срока ожидания повторного предоставления документов поставщиком (после чего проверка завершается автоматически с отрицательным результатом).

Настройки бизнес-процесса для робота проверки поставщиковНастройки бизнес-процесса для робота проверки поставщиков

Когда в процессе проверки поставщикасотрудник выявляет недочеты, он устанавливает переключатель в положение "Не соответствует" и указывает причину в комментарии.

Сотрудник выявил несоответствия при проверкеСотрудник выявил несоответствия при проверке

Контрагент получает автоматическое письмо по emailдля устранения выявленных замечаний:

1 - Замечание по каждому документу.

2 - Уникальная ссылка с ограниченным сроком действия.

Робот автоматически отправляет email поставщику со ссылкой для повторного предоставления документовРобот автоматически отправляет email поставщику со ссылкой для повторного предоставления документов

Форма на сайте, открытая по уникальной ссылке из письма, предлагает загрузитьтолько те документы, которые запрошеныу контрагента повторно.

Пример формы на сайте для повторного запроса документов
Сгенерированная форма на сайте для повторной отправки документов для проверки контрагентаСгенерированная форма на сайте для повторной отправки документов для проверки контрагента

Циклов повторного запроса может быть несколько, пока контрагент не устранит все замечания и не предоставит требуемые от него документы.

Пример истории почтовых писем от робота по одной проверке поставщика
История автоматических писем в ERP в документе проверки контрагентаИстория автоматических писем в ERP в документе проверки контрагента

Все шаблоны автоматических писем настраиваются в ERP (в пользовательском режиме).

Пример настройки почтовой подсистемы для робота проверки поставщиков

1 - Настройки почтовых ящиков для уведомлений.

2 - Настройка почтовых ящиков для загрузки данных с сайта.

3 - Настройка шаблонов автоматических email для различных событий.

Настройки почтовой подсистемы робота проверки поставщиковНастройки почтовой подсистемы робота проверки поставщиков

Плюсы роботизации бизнес-процесса проверки поставщиков:

1. Из процесса проверки исключен один сотрудник - менеджер по закупкам.

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

2. Поставщики самостоятельно предоставляют данные через сайт.

Высвобождается время у менеджера по документообороту для запроса документов по email, и у поставщика для выяснения вопросов и пересылки писем.

3. Робот автоматически проверяет поставщиков по 10 признакам.

И отклоняет проверку самостоятельно в случае выявления недочетов. У сотрудников высвобождается время для выполнения других задач.

4. Робот автоматически ставит задачи сотрудникам разных подразделений.

Сотрудникам не нужно думать о том, кому передать результат своей работы, а задачи не будут забыты или просрочены.

5. Робот автоматически пишет письма поставщикам.

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

6. Не нужно увеличивать штат сотрудников при увеличении числа поставщиков.

А количество поставщиков увеличивается, так как менеджер по закупкам, в высвободившееся время может заниматься поиском новых поставщиков и более тщательной проработкой наилучших условий поставки.

7. Новые поставщики приходят самостоятельно через сайт компании.

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

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

Настройки и функционал робота проверки статуса ликвидации организаций
Настройки робота проверки статуса ликвидации всех контрагентов в ERPНастройки робота проверки статуса ликвидации всех контрагентов в ERP

Ежедневно ночью робот автоматически проверяет всю базу контрагентов в ERP и актуализирует статус ликвидации по каждому ИП и юр. лицу (интеграция с сервисом DaData.ru).

При обнаружения признака ликвидации у контрагента:

1 - Робот автоматически уведомляет ответственных сотрудников.

2 - Робот приостанавливает оплаты в счет контрагента.

3 - Робот маркирует карточку контрагента в ERP.

Виджет проверки статуса ликвидации в карточке поставщика в ERPВиджет проверки статуса ликвидации в карточке поставщика в ERP

Кейс #2: Роботизация процесса запроса КП у поставщиков

Процесс сбора, обработки и согласования коммерческих предложений не менее трудоемкий, чем процесс проверки поставщиков:

  • Поиск поставщиков и подрядчиков.

  • Запрос коммерческих предложений и их сбор.

  • Сравнение предложений и выбор наилучшего поставщика.

  • Подготовка аналитической записки и согласование с руководителем.

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

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

  • ERP-система (рабочее место сотрудников).

  • Сайт компании (интеграция с ERP).

  • Почта Gmail (интеграция с ERP и сайтом).

Рабочее место менеджера по закупкам в ERP с функционалом запроса КП через сайт

Шаг 1:Менеджер выбирает позиции (1) для запроса предложений через сайт (2).

Выбор позиций номенклатуры для запроса КП через сайтВыбор позиций номенклатуры для запроса КП через сайт

Выбор позиций номенклатуры для запроса КП через сайт

Шаг 2:Робот автоматически выбирает (1), и показывает менеджеру подходящих поставщиков (2) и контактных лиц (3) с заполненными email (4), а также компетенции, результаты проверок и последние поставки (5).

Робот выбрал подходящих поставщиков и контактных лиц для запроса КПРобот выбрал подходящих поставщиков и контактных лиц для запроса КП

Шаг 3:Менеджер выполняет визуальную проверку и запрашивает КП через сайт.

Шаг 4:Для каждого контрагента робот автоматически создает письмо со ссылкой для заполнения коммерческого предложения.

Робот создал и отправил письма с запромо КП каждому поставщикуРобот создал и отправил письма с запромо КП каждому поставщикуПисьма, отправленные роботом из ERPПисьма, отправленные роботом из ERP

На сайте разработана динамическая форма для заполнения поставщиком коммерческого предложения:

1. Условия оплаты (налогообложение, цены с НДС или без, ставка НДС,наличие отсрочки и размер аванса).

2. Условия поставки (способ и срок доставки, наличие и срок гарантии).

3. Товары к поставке (цены, особые условия гарантии, сроков поставки или аналоги).

Пример формы на сайте для заполнения КП поставщиком
Форма на сайте для заполнения коммерческого предложения поставщикомФорма на сайте для заполнения коммерческого предложения поставщиком

Таблицу товаров к поставке можно скачать в Excel.

Пример адаптивной формы на сайта для заполнения КП на смартфоне
Форму коммерческого предложения можно заполнить прямо в смартфонеФорму коммерческого предложения можно заполнить прямо в смартфоне

Важно!Чтобы поставщик не тратил время на подготовку формы коммерческого предложения, ее можно:

  • Скачать уже заполненную прямо на сайте.

  • Распечатать, поставить подпись и печать.

  • Отсканировать и загрузить на сайт.

Пример заполненной формы коммерческого предложения от лика поставщика
Печатная форма коммерческого предложения для скачивания с сайтаПечатная форма коммерческого предложения для скачивания с сайта

Одну форму коммерческого предложения на сайте можно заполнять несколько раз.Заполненные данные автоматически сохраняются в кеш и не будут потеряны.

Такие образом, все коммерческие предложения поступают в ERP автоматически.

После окончания срока приема предложений, в ERP автоматически формируется аналитическая записка.

Робот автоматически формирует аналитическую запискуи сравнивает все предложения от поставщиков по одним и тем же критериям (1), и ранжирует претендентов (2) в порядке убывания.

Аналитическая записка, сформированная роботом в ERPАналитическая записка, сформированная роботом в ERP

Робот запроса и обработки коммерческих предложений завершает свою работу, и ставит задачу менеджеру по закупкам для проверки аналитической записки и дальнейшего согласования закупки с руководителем.

Мы разработали и других роботов, которые работают в режиме 24/7 и облегчают жизнь.

Спасибо, что дочитали до конца!

Задавайте вопросы, постараюсь на все ответить.

Подробнее..

Tableau Hyper API BI-команда скажет вам спасибо

28.08.2020 12:23:00 | Автор: admin
Мы хотим рассказать вам о том, как мы помогли нашей BI-команде организовать автоматический процесс доставления данных на Tableau-сервер из MongoDB, используя таблошный формат хранения данных hyper, а сам процесс настройки формирования данных осуществляется через простой веб-интерфейс.

В начале коротко расскажем, как выглядел процесс до и после того, как мы научили наш внутренний продукт А1 программно собирать датасорсы и публиковать их на Tableau Server. Затем подробнее разберем проблему BI-команды и найденное решение, а также заглянем под капот (здесь о создании .hyper файла, публикации файла на tableau-сервере и обновлении хайпера). Добро пожаловать под кат!

Tableau Hyper API BI-команда скажет вам спасибо


Мы в рекламной группе DAN много работаем с данными мониторинга рекламы от Mediascope, индустриального измерителя на медиарынке. Сценарии бывают разные: некоторые сотрудники выгружают сырые данные, другие пользуются готовыми предобработанными базами, а кто-то заказывает разработку автоматизированных дашбордов на этих данных. О последнем сценарии расскажем подробнее дашборды наши BI-разработчики собирают в Tableau, но прежде чем начать рисовать, им тоже необходимо привести данные к нужному удобному для разработки формату.

Жизненный путь данных от сырья до красивых автоматизированных графиков можно условно разбить на 4 шага:
  1. Получение сырых данных
  2. Чистка и доработка данных
  3. Создание источников данных для Tableau
  4. Разработка визуализаций


Было


До того, как мы научились программно генерировать источники данных для Tableau, процесс выглядел так:
Старый жизненный процесс

1. Получение сырых данных
Пользователи формируют табличные-отчеты через внутренний инструмент А1. О нем мы подробнее расскажем далее.

2. Чистка и доработка данных
Возможность трансформации данных также заложена в инструмент А1, после чего очищенные данные можно выгрузить в xslx/csv и продолжить с ними работу вне инструмента. Тут стоит отметить, что некоторые пользователи ограничиваются 1ым пунктом и после выгрузки отчетов дорабатывают данные своими силами.

3. Создание источников данных для Tableau
Раньше заказчики дашбордов приходили с набором экселей, которые они сгенерировали на предыдущих пунктах. А BI-разработчики сводили эти эксели в единый датасорс (таблошный сленг) своими силами. Не всегда удавалось ограничиться только инструментами Tableau, часто писали скрипты на Python.

4. Разработка визуализаций
Наконец, вершина айсберга создание дашборда и публикация его на Tableau Server, где его увидит заказчик. На практике визуализация часто занимает меньше времени, чем сбор данных и настройка обновлений.

Боль копилась на третьем шаге, так как росло количество кастомных решений, которые было затратно поддерживать и реализовывать. Также регулярно просачивались ошибки в данных со второго шага промежуточный эксель между двух систем (А1 и Tableau) прямо-таки подталкивал пользователя: давай поправь что-нибудь ручками, никто не заметит.

Стало


Основной задачей было исключить эксели между 2 и 3 шагом. В итоге мы научили А1 собирать датасорсы и публиковать их на Tableau Server. Вот что получилось:
Новый жизненный процесс

Сейчас шаги с 1 по 3 происходят в А1, на выходе BI-команда получает опубликованный на Tableau Server датасорс для разработки визуализаций. Связующим звеном стал Hyper API, о котором дальше и пойдет речь.

Результаты


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

Освободили время BI команды. Раньше было мало шаблонных решений и много кастомизаций. Чаще всего под каждый проект дописывали обработку на Python. В редких случаях, где обработка не нужна была, работали сразу в Tableau Desktop (основной инструмент разработки).
Сейчас подготовка датасорса это: накликать нужные поля в интерфейсе А1, отметить, какие из них разворачиваем в строки (если это необходимо) и опционально заранее определить тип полей.

Не нагружаем Tableau Server обновлением громоздких датасорсов обновление происходит силами А1, а на сервер закачивается уже готовый hyper.

*Бонус стимулируем пользователей работать внутри А1. Если раньше некоторые пользователи после выгрузки сырых отчетов, дорабатывали их вручную вне инструмента, то теперь, так как весь процесс с 1 по 3 шаг происходит в А1, пользователям проще настраивать процесс чистки там же.

Проблема и решение


Немного о А1


Прежде чем мы начнем рассказывать про наше решение, нужно рассказать о нашем внутреннем продукте А1, к которому прикрутили генерацию датасорсов.

А1 это внутренний продукт компании, который призван упростить рабочий процесс сотрудникам, у которых основная работа заключается в следующем:
  • Забирать данные из программных продуктов компании MediaScope
  • Приводить эти данные (чистить) в удобный для аналитиков-предметников вид
  • По необходимости подготавливать данные для создания дашбордов (об этом мы сегодня и поговорим)

После того, как пользователь завершает чистку данных, они хранятся в системе А1. В нашей терминологии это называется Контейнером. Контейнер это обычный документ в MongoDB, который нам и надо передавать на Tableau-сервер.

Проблема BI-команды


Нашей команде BI-разработчиков нужно было как-то получать данные из А1, которые хранились в MongoDB, и на основе полученных данных строить дашборды. В первую очередь мы попробовали забирать данные из MongoDB штатными средствами табло, но проблему это не решало:
  • Поскольку данные хранятся в MongoDB, то на вход в табло поступают данные с произвольной структурой, а это значит, что постоянно пришлось бы заниматься поддержкой данной логики.
  • Для агрегации данных из MongoDB нужно было тащить определенные записи из коллекции, а не коллекцию целиком драйвер Tableau делать это не умеет.
  • Кроме всего прочего, мало было получить данные: иногда их нужно было разворачивать делать unpivot некоторых столбцов в строки. Что тоже не так просто было сделать, от слова совсем.

Что мы придумали


Было принято решение попробовать решить данную задачу своим велосипедом, используя библиотеку Tableau Hyper API. Данная библиотека позволяет создавать файл в формате .hyper, в который легко складывать данные, а потом использовать как источник данных для создания дашборда на табло сервере.

Как описывают хайпер сами разработчики табло:
Hyper это высокопроизводительный in-memory механизм обработки данных, который помогает клиентам быстро анализировать большие или комплексные датасеты, эффективно оценивая запросы в базу данных. Основанная на платформе Tableau, Hyper использует собственные методы динамической генерации кода и передовые технологии параллелизма, позволяющие достигать высокой производительности при создании экстрактов и выполнение запросов.

Примерный процесс работы в нашей программе выглядит следующим образом:
  • Пользователь выбирает контейнеры и нужные колонки
  • Система выдергивает из контейнеров данные
  • По полученным данным система определяет типы колонок
  • Инициализируется создание хайпера и вставка в него данных
  • Загружается хайпер на табло-сервер
  • BI-разработчики видят хайпер на сервере и создает на его основе дашборд

Когда в контейнеры зальют новые данные системе будет подан сигнал, что нужно обновить хайпер:
  • Система скачает с табло-сервера хайпер
  • Заберет из MongoDB свежие данные и обновит хайпер
  • После система загружает на сервер новый хайпер с перезаписью существующего
  • Пользователю достаточно просто нажать на кнопку Refresh, чтобы в дашборде отобразилась актуальная информация


Что видит пользователь


Как уже говорилось ранее, А1 является веб-приложением. Для создания сервиса генерации хайпера на фронте мы использовали Vue.js и Vuetify.

Интерфейс приложения разделен на три экрана.
Экран с выбором контейнеров

На первом экране пользователь выбирает нужные контейнеры и колонки.

Если включена опция Unpivot, то в хайпере будут созданы две дополнительные колонки: variable- наименования колонок, которые выбираются столбцом Metrics и values- значения из этих колонок.

Столбец Dimension добавляет в хайпер колонку с одноименной выбранной колонкой. Количество выбранных колонок Dimensions и их названия должны быть одинаковые во всех контейнерах, чтобы не нарушилась целостность таблицы в хайпере, поэтому присутствует столбец Имя hyper, который позволяет указать имя выбранной колонке, если в контейнерах они называются по-разному.
Логи процесса создания хайпера
На этом процесс настройки хайпера заканчивается. Пользователю достаточно перейти на второй экран, нажать Создать hyper и наблюдать за ходом событий в логах.

Дополнительные настройки

На третьем экране находятся дополнительные настройки:
  • Можно включить игнорирование обновлений, если нам не нужно, чтобы система автоматически обновляла хайпер
  • Можно указать email, на который отправлять отчеты об обновлениях
  • Можно руками указать тип данных для колонки values (используется только при unpivot режиме): float, string или автоматически определять системой (про типы поговорим дальше)
  • Также можно указать типы данных для выбранных колонок у контейнеров.


Что под капотом


А1 написан на Python. Для работы с данными мы используем Pandas, а сами данные мы сериализуем из pandas в pickle и храним в MongoDB GridFS.

Когда поступает команда на создание хайпера, система выполняет следующие операции:
  • Выгружает из MongoDB все необходимые контейнеры и десиреализует данные в датайфремы pandas
  • Производит подготовку данных: оставляет в датафреймах только нужные колонки, дает им новые имена, при необходимости разворачивает таблицы через pandas.melt
  • Если пользователь выставил тип данных у колонок, то произвести конвертацию данных либо во float32, либо в string
  • После всех подготовительных работ с данными система через hyper api создает файл и через tabcmd отправляет файл на табло-сервер.

Стоит немного поговорить о типах данных у колонок. Одной из особенностей хранения данных в контейнерах А1 является то, что пользователи не заморачиваются над тем, какие типы назначать колонкам, за них это прекрасно делает pandas: система спокойно справляется с ситуациями, когда в колонке присутствуют числа и строковые значения. Однако хайперу это не нравится: если сказать ему, что колонка должна иметь тип int, то система ругнется при попытке вставить что угодно кроме целого числа. Поэтому было принято решение использовать в хайперах только два типа данных: string и float.

Итак, с общим принципом работы мы разобрались, давайте поговорим уже про саму работу с hyper.

Создание .hyper файла


Для работы с Hyper API понадобится установить библиотеку, скачать ее можно с официально сайта тут. Там же есть неплохие примеры, как работать с этим инструментом. Мы же кратко укажем основные моменты.

Сам файл хайпера из себя представляет эдакую базу данных, чем-то напоминает SQLite.Через api можно обращаться к данным, используя like SQL синтаксис:
f"SELECT {escape_name('Customer ID')} FROM {escape_name('Customer')}"

Поскольку система у нас написана на Python, то и библиотеку мы будем использовать для соответствующего языка. При создании файла мы должны указать имя схемы, имя таблицы и колонки с типами. Имя схемы и таблицы должны называться Extract, поскольку именно в эту схему с таблицей залезает Tableau Server, чтобы вытащить данные для книжек.

with HyperProcess(Telemetry.SEND_USAGE_DATA_TO_TABLEAU) as hyper:    with Connection(        hyper.endpoint, self.fullpath_hyper, CreateMode.CREATE_AND_REPLACE    ) as connection:        connection.catalog.create_schema("Extract")        main_table = TableName("Extract", "Extract")        example_table = TableDefinition(main_table)

После создания таблицы нам нужно создать колонки и задать типы. Как мы уже говорили ранее, данные у нас имеют только два типа (float или string), поэтому отталкиваясь от того, какой тип стоит у колонок в датафрейме, такой мы и выставляем для колонок:

for column in dataframe.columns:    if dataframe[column].dtype.name in ("category", "object"):        example_table.add_column(TableDefinition.Column(column, SqlType.text()))    elif dataframe[column].dtype.name in ("float32"):        example_table.add_column(            TableDefinition.Column(column, SqlType.double())        )    connection.catalog.create_table(example_table)

После создания таблицы можно и вставить данные:

with Inserter(connection, example_table) as inserter:    for val in dataframe.values:        inserter.add_row(val.tolist())    inserter.execute()

Здесь мы построчно бежим по датафрейму и накапливаем список значениями через inserter.add_row(). На самом деле в апи хайпера есть функция add_rows(), которая принимает список списков и вставляет уже значения. Почему не было так сделано? Ради экономии оперативной памяти: для того чтобы предоставить список списков значений из датафрейма, нужно попросить pandas сделать values.tolist(). А когда у тебя 150 млн строк данных, получается очень дорогая операция для оперативки, при этом на производительности это никак не сказывается (во всяком случае, не было замечено, что из-за итерационного перебора строк как-то просела скорость создания хайпера). Плюс ко всему, add_rows() работает как синтаксический сахар: на деле он принимает список списков и так же итерационно добавляет данные.

На этом создание нашего хайпера заканчивается. Дальше нам нужно опубликовать его на сервере.

Публикация файла на tableau-сервере


Для обращения к tableau-серверу мы будем использовать утилиту tabcmd это консольная утилита, позволяющая подключаться к серверу и выполнять административные функции создавать пользователей, группы, книжки и прочее.

Запускать команду tabcmd будем через питоновский subprocess.Popen:

popen = subprocess.Popen(    f'/opt/tableau/tabcmd/bin/tabcmd publish "{fullpath_hyper}" -n "{filename}" -o -r "A1_test" '    '-s http://tableau.domain.com -u "username" -p "password" --no-certcheck',    shell=True,    stderr=subprocess.PIPE,    stdout=subprocess.PIPE,)return_code = popen.wait()if return_code:    error = str(popen.communicate()[1])    return f"Ошибка сервера во время публикации файла. {error}"

Мы передаем tabcmd следующую команду и ключи:
  • publish: залить файл на сервер
  • -n (--name): какое имя файла будет на сервере
  • -o (--overwrite): если присутствует файл с таким именем, то перезаписать
  • -r A1_test (--project): положить файл в папку (он же проект)
  • -s (--server): адрес tableau-сервера
  • -u -p: логин и пароль для авторизации на сервере
  • --no-certcheck: игнорировать проверку SSL-сертификата

Обновление хайпера


Как создавать новый хайпер мы разобрались, но что делать, когда хайпер состоит из десятка контейнеров и в один из них поступили новые данные? Будем обновлять хайпер.

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

Чтобы понимать, какие данные из какого контейнера лежат в хайпере, система при создании хайпера также создает дополнительную колонку container_id. При таком подходе обновление становится очень простым:
  • Забираем файл с сервера
  • Удаляем все строки в хайпере, где container_id равняется обновляемому контейнеру
  • Вставляем новые строчки
  • Загружаем обратно на сервер файл с перезаписью.

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

Для того, чтобы забрать файл, мы используем tabcmd:

popen = subprocess.Popen(    f'/opt/tableau/tabcmd/bin/tabcmd get "datasources/{filename_tdsx}" '    f'-s http://tableau.domain.com -u "username" -p "password" '    f'--no-certcheck -f "{fullpath_tdsx}"',    shell=True,    stderr=subprocess.PIPE,    stdout=subprocess.PIPE,)return_code = popen.wait()if return_code:    error = str(popen.communicate()[1])    return f"Ошибка. {error}"

Тут используем следующую команду и ключи:
  • get: забрать с сервера файл. Если на сервере лежит файл test.hyper, то обращаться надо к файлу test.tdsx, а лежат они все в директории datasource (я не смог нагуглить, почему такая особенность работы в табло, если знаете, поделитесь в комментариях )
  • -f (--filename): полный путь, включая имя файла и его расширение, куда надо сохранить файл

После того, как файл будет скачен, его надо разархивировать через zipfile:

with zipfile.ZipFile(fullpath_tdsx, "r") as zip_ref:    zip_ref.extractall(path)

После разархивации хайпер будет лежать в директории ./Data/Extracts.

Теперь, когда у нас есть актуальная версия файла, мы можем удалить из него ненужные строки:

table_name = TableName("Extract", "Extract")with HyperProcess(Telemetry.SEND_USAGE_DATA_TO_TABLEAU) as hyper:    with Connection(hyper.endpoint, self.fullpath_hyper) as connection:        connection.execute_query(            f"DELETE FROM {table_name} WHERE "            f'{escape_name("container_id")}={container_id}'        ).close()

Ну а вставка и публикация файла были уже описаны выше.

Заключение


Что в итоге? Проделав работу по внедрению генерации hyper-файлов и автоматической доставки их на tableau-сервер, мы в разы снизили нагрузку на BI-команду, данные в дашборде обновлять стало проще и, самое главное, быстрее. Само знакомство с hyper api не было болезненным, документация неплохо написана, а сама интеграция технологии в нашу систему прошла легко.

Благодарим за внимание! Если у вас есть вопросы или замечания, пожалуйста, оставляйте их в комментариях.

Статья написана совместно с Василием Лавровым (VasilyFromOpenSpace) Старшим разработчиком бизнес-аналитики
Подробнее..

Автозаказ как сделать так, чтобы нужные продукты сами попадали на полки 17000 магазинов по всей стране

17.06.2021 18:06:38 | Автор: admin

В школе все мы решали задачки вида едет из пункта А в пункт Б. Речь преимущественно шла о скорости и времени как быстро доберётся транспортное средство? Реальность, однако, подбрасывает задачки значительно интереснее: Существует масштабная ритейл-сеть по продаже товаров, которой необходимо, чтобы огромное количество номенклатурных позиций доезжало в каждый из 17000 магазинов, расположенных на половине площади самой большой страны в мире, вовремя и в нужном количестве. Для решения такой задачи в X5 Group существует ряд реализованных решений, и одним из самых важных является процесс автозаказа товаров.

Техническую поддержку этого направления в X5 Group обеспечивает команда 2-SAP Логистики.

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

Автозаказ это комплекс процессов управления запасами и заказами

Поддержание объемов товарных запасов на установленном уровне, который обеспечивает постоянное наличие товара при оптимальных запасах в системе. Это достигается путем автоматического выполнения процессов:

Планирование заказа.

Формирование заказа.

Отправка заказа.

Экономическое обоснование поддерживаемого уровня.

Контроль за состоянием запасов.

Управление ассортиментом.

Для этого используются разные механизмы прогнозирования в зависимости от формата магазина и гибких настроек в системе.

Прогноз продаж, построенный SAP это прогноз спроса на период 42 дня, который строится на исторических данных по продажам. Период анализа продаж и модель прогноза определяется из настройки Профиля прогноза.

В периоде анализа продажи могут быть Плановые регулярные продажи и Внеплановые промо продажи.

Прогноз запускается ночью (по календарю) в фоновом режиме массово для всех комбинаций товар-магазин, в рамках ассортимента и является частью Автозаказа.

Модель прогноза определенный алгоритм расчета прогноза, который устанавливается для каждого товара в магазине.

Для больших форматов прогнозирование и создание рекомендаций для Автозаказа производится в JDA, алгоритмы и логика расширены в данной системе и позволяют учитывать огромное количество параметров для расчета.

Около 04:30 эти данные по потребности из JDA поступают в SAP, где обрабатываются фоновым заданием с интервалом запуска каждые 15 минут, в результате чего создаются Автозаказы.

Автозаказ подскажет, какие товары из-за их низкой оборачиваемости можно вывести из постоянного товарооборота, какие заказные товары пользуются популярностью и их можно вводить в постоянный ассортимент, уменьшаются ли остатки по товарам, выводимым из ассортимента и т.п.

Большую роль этот функционал играет для персонала магазинов магазинах, так как он исключает необходимость формировать заказы вручную. За исключением отдельных случаев всё происходит в автоматическом режиме с учетом большого количества факторов и сложных расчетов. Автозаказ облегчает ежедневный труд тысяч сотрудников по всей стране от Калининграда до Новосибирска.

Через автозаказ пополняется до 80% основного ассортимента магазинов, а ручное пополнение выполняется для товаров in-out.

Схема реализации товара:

Классическое пополнение происходит примерно в таком виде: представим, что есть некий товар, допустим, это пакет молока, который поставляется напрямую от внешнего поставщика в магазин торговой сети Пятёрочка.

Так выглядит децентрализованная цепочка:

- в 00:00 стартует задание по АЗ, в период его работы по нашему товару рассчиталась потребность, сформировался автозаказ, и он бы отправлен из SAP в программу магазина GK(Пятёрочка), там у сотрудников магазина есть время отредактировать рассчитанное кол-во (в большую или меньшую сторону) до наступления времени автосогласования (например, до 11 утра, время зависит от тайминга поставщика, т.е. до какого момента он должен получить заказ, чтобы собрать и вовремя привезти заказ в магазин);

- после внесения корректировок эти изменения отправляются в SAP, и дальше происходит процесс автосогласования заказ согласовывается и автоматически отправляется поставщику по почте или его провайдеру (в зависимости от того, по какой схеме поставщик работает). Например, если поставщик работает по EDI (через электронный документооборот), то после доставки заказа в его систему, нам провайдер передает информацию о размещении заказа в своем ПО и в системе ER2 заказ переходит в статус Размещен;

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

Автозаказ (Ночной профиль) фоновое задание, имеющее внутри себя несколько шагов, выполняющих определённые функции. Результатом задания является создание заказов на закупку товаров. Запускается параллельно по магазинам.

На ежедневной основе автозаказ формирует более 400 000 тысяч заказов для магазинов, как на распределительные центры, так и внешним поставщикам.

  • Продажи за период в прошлом

  • Прогноз на период в будущее (на основании продаж строится прогноз SAP)

  • Остатки на объекте получателе (остаток в SAP на момент расчёта)

  • Открытые заказы (заказы на поставку и возвраты)

Продажи приходят из магазинов каждый вечер до расчёта фонового задания Автозаказа.

Факт получения продаж запускает расчет Автозаказа. Если до 03:00 продажи не получены SAP ERP, то происходит безусловный запуск задания Автозаказа.

Автозаказ формируется строго по графику заказа/поставки согласованному с поставщиком, так категории FRESH и ULTRA FRESH, как правило, заказываются и поставляются в магазины ежедневно.

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

Для того, чтобы заказать оптимальное кол-во товара, учитывается прогноз, который строится в SAP в момент расчета Автозаказа.

Если по этому товару уже есть запас или размещенный заказ, который еще не закрыт, то система также учтёт это при определении размера финального заказа.

Автозаказ определяет цепочку и поставщика по каждому товару для каждого магазина, согласно записей книги источников поставок (смежный функционал, автоматизирован, и ведется также в SAP) и формирует заказ.

Запускается задание ежедневно в 00:00. Первыми выполняются расчеты по регионам Сибирь и Урал, далее по регионам Волга, ЮГ, Москва, Центральные регионы и Северо-запад. Последовательность выполнения крайне важна, т.к. наши магазины расположены в разных часовых поясах, и пока в Москве все сладко спят, в Сибири уже в разгаре рабочий день.

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

Для этого мы реализовали различные системы мониторинга, инструменты для анализа, а в случае необходимости и возможность отправки в магазины резервных шаблонов заказа.<o:p>

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

Также предусмотрен инструмент для выгрузки резервных заказов, рассчитанных накануне, в случае сбоев в системе JDA по прогнозированию и пополнению магазинов больших форматов Перекрёсток и Карусель.

Подробнее..

Автоматическая очистка корзины Yandex.Disk без участия человека

18.01.2021 22:06:54 | Автор: admin

Озаботившись бэкапами на своем скромном сервере я столкнулся с очевидным дальнейшим шагом а где все это добро хранить?


Выбора особо нет да и у меня есть халявный Яндекс Диск на котором со всеми бонусами у меня аж 63 Гб, грех не воспользоваться.


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


Давайте потратим минут 10-15 и на год забудем о проблеме, поехали.


Вводные данные на чем у меня все работает:


Ubuntu 18.04Yandex.Disk консольный клиент для одноименного дистрибутива

  1. Зайдем под тем логином из по которого работает ваш ЯД по адресу https://oauth.yandex.ru/ и нажмем кнопочку Зарегистрировать новое приложение



  2. Заполняем поля как указано на скриншоте



  3. В пункте Доступы выберите Яндекс Диск REST API и поставьте галочки как на скриншоте



  4. Спускаемся в самый низ страницы и жмем кнопку Создать приложение



  5. В полученной странице есть все необходимые нам данные, обязательно сохраните их (PS мои реальные данные я удалил но сохранил вам на скрине для наглядности, у вас они будут другие)



  6. Получим токен, зайдем браузером по адресу https://oauth.yandex.ru/authorize?response_type=token&display=popup&client_id=ВАШid где в самом конце укажем ID полученный в шаге 5


  7. На этой странице жмем Разрешить



  8. На этой странице увидим выданный токен, обязательно сохраните его!



  9. Зайдем в консоль вашего сервера под рутом и создадим скрипт


    nano /root/yadisk.sh
    

    В котором пропишем следующие команды


    #!/bin/sh/usr/bin/curl -s -H "Authorization: OAuth ваш_токен" -X "DELETE" https://cloud-api.yandex.net/v1/disk/trash/resources/?path=
    

    где на месте ваш_токен внесем данные из шага 8


  10. Сохраним скрипт и сделаем его исполняемым


    chmod 700 /root/yadisk.sh
    

  11. Дадим команду crontab -e
    в открывшемся окне напишем


    0 3 * * * /root/yadisk.sh > /dev/null 2>&1
    

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



Ну вот и все, я дольше писал статью и делал скриншоты чем вы все это сможете реализовать у себя :)
Маленький совет я решил очищать корзину ДО того как будет запускаться следующий мой скрипт, который бэкапит базы данных моих сайтов, таким образом я могу быть чуть более спокоен что места на диске хватит для моих данных.


Если хотите убедиться что вы все сделали правильно то можете просто запустить эту команду в консоли


/usr/bin/curl -s -H "Authorization: OAuth ваш_токен" -X "DELETE" https://cloud-api.yandex.net/v1/disk/trash/resources/?path=

(не забудьте в эту строку внести ваш_токен)
и убедиться что корзина вашего аккаунта пуста.


На этом разрешите откланяться.

Подробнее..

Автоматизируем ведение большого количества пользователей в AD

21.04.2021 20:21:36 | Автор: admin
Автоматизируем ведение большого количества пользователей в AD:

Добрый день! В этой статье я бы хотел описать применённое мной практическое решение по автоматизации одной рутинной задачи второй линии технической поддержки одного крупного предприятия.

Имеем два территориально распределённых домена AD по 10 000 человек, применённое решение по организации Веб-доступа к удаленным рабочим столам через приложения RemoteApp с несколькими интегрированными информационными системами и активно пополняющиеся база, человек так на 500 в месяц. На ~24 в рабочий день, на ~3 человека в час.

Первая очевидный вывод из входных данных на таком количестве пользователей один админ не справится, у него должно быть право заболеть/уйти в отпуск не парализовав предприятие. А практика показывает, что и два не справляются.

Вторая проблема идентификация личностей, допустим на файловых ресурсах предприятия, как это часто бывает, имеется информация, не предназначенная для посторонних глаз, и соответственно необходимо проверять каждого запросившего доступ на внесение в Active Directory и предоставления определённых групп доступа. К сожалению, без бюрократии в решении этого вопроса обойтись не удалось. Процедура сводится к подаче бумажной заявки в форме максимально стандартизированной, за подписью (желательно электронной) руководителя заявляющего и одобрением данного документа лицами знакомыми лично с подписантом.

После одобрения стандартизированной заявки остается дело за малым, внести людей в AD, присвоить необходимые группы доступа, и внести в excel табличку. Последний пункт может показаться немного архаичным, ведь AD вполне себе поддерживает аудит изменений, но моя практика показывает, что на таком обороте этот пункт не является лишним, а даже упрощает процесс поиска граблей в случае разбора полётов, который не редко возникает, следуя из первого вывода.

Но процесс можно немного автоматизировать, применив пару нехитрых скриптов. Логика сводится к обратному процессу:
1) Утверждаем стандарт внесения Учётных Записей в AD на предприятии
2) Запрашиваем у пользователя данные едином формате.
image
3) Вносим в таблицу основные данные, например:
4) Экспортируем из Excel в CSV файл, автоматически сгенерированную страницу, пригодную для автоматического занесения в AD при помощи скриптов
5) Экспортируем и вуаля! Остаётся передать логин и пароль пользователю.
Возможно описанные мной методы нельзя назвать best practice, однако они позволяют на практике решить существующую проблему без написания отдельно информационной системы и создания большого количества интеграций.

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

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

Как вы думаете какими будут разделители если открыть сгенерированный файл блокнотом? Неправильно. Такими ;

Отдельно в моей реализации следует остановиться на столбце транслит. В утверждённом нами стандарте часть полей заполняется транслитом по утверждённому образцу и чтобы не делать это каждый раз я использовал vba скрипт, вот он:
Function TranslitText(RusText As String) As String    Dim RusAlphabet As Variant 'массив из букв русского алфавита    RusAlphabet = Array("-", "а", "б", "в", "г", "д", "е", "ё", "ж", "з", "и", "й", "к", "л", "м", "н", "о", "п", "р", "с", "т", "у", "ф", "х", "ц", "ч", "ш", "щ", "ъ", "ы", "ь", "э", "ю", "я", "А", "Б", "В", "Г", "Д", "Е", "Ё", "Ж", "З", "И", "Й", "К", "Л", "М", "Н", "О", "П", "Р", "С", "Т", "У", "Ф", "Х", "Ц", "Ч", "Ш", "Щ", "Ъ", "", "Ь", "Э", "Ю", "Я")     Dim EngAlphabet As Variant 'массив из букв английского алфавита    EngAlphabet = Array("-", "a", "b", "v", "g", "d", "e", "yo", "zh", "z", "i", "y", "k", "l", "m", "n", "o", "p", "r", "s", "t", "u", "f", "kh", "ts", "ch", "sh", "sch", "", "y", "", "e", "yu", "ya", "A", "B", "V", "G", "D", "E", "Yo", "Zh", "Z", "I", "Y", "K", "L", "M", "N", "O", "P", "R", "S", "T", "U", "F", "Kh", "Ts", "Ch", "Sh", "Sch", "", "Y", "", "E", "Yu", "Ya")         Dim EngText As String, Letter As String, Flag As Boolean                 For i = 1 To Len(RusText) 'цикл по всем символам русского текста        Letter = Mid(RusText, i, 1)        Flag = 0        For j = 0 To 67 'цикл по всем буквам русского алфавита            If RusAlphabet(j) = Letter Then 'если символ из текста совпал с буквой из русского алфавита...                Flag = 1                If RusAlphabet(j) = Letter Then 'проверка на регистр (верхний или нижний)                    EngText = EngText & EngAlphabet(j) '... то добавляем соответствующую букву из английского алфавита                    Exit For                Else                    EngText = EngText & UCase(EngAlphabet(j))                    Exit For                End If            End If        Next j        If Flag = 0 Then EngText = EngText & Letter 'если символа из текста в алфавите нет (например, знаки препинания и т.п.), то добавляем символ без изменения    Next i    TranslitText = EngTextEnd Function


Не делайте как я, пожалуйста, используйте один из существующих стандартов транслитерации по ссылке habr.com/ru/post/499574

Следующий же скрипт помещённый в файл с расширением .ps1 позволит вам в пару кликов закинуть все учётные записи из сгенерированного на предыдущем шаге файла в AD, как бы много их там не было. А заодно и навесить на все созданные УЗ группу ad-group.
Import-Module activedirectory Import-Csv "C:\generated.csv" -Encoding default -Delimiter ';'| ForEach-Object {New-ADUser -Server DOMEN.RU -Name $_.FirstName `-DisplayName $_.DisplayName `-GivenName $_.GivenName `-Surname $_.LastName `-Initials $_.Initials `-OfficePhone $_.Phone `-Description $_.Description `-UserPrincipalName $_.UserPrincipalName `-SamAccountName $_.samAccountName `-Email $_.mail `-Path "OU=TEST_OU,OU=Guest,OU=Users,OU=DOMEN,DC=DOMEN,DC=RU" `-AccountPassword (ConvertTo-SecureString $_.Password -AsPlainText -force) -Enabled $true Set-ADuser $_.samAccountName -ChangePasswordAtLogon $True Add-AdGroupMember -Identity ad-group  -Members $_.samAccountName} 
Подробнее..

Автоматизация ручных действий с GitHub Actions

12.01.2021 18:23:11 | Автор: admin

GitHub Actions инструмент для автоматизации рутинных действий с вашего пакета на GitHub.

Из личного опыта расскажу, как без опыта и знаний о настройке CI, я научился автоматизировать рутину в своем Open Source проекте всего за день и что на самом деле это действительно не так страшно и сложно, как многие думают.

GitHub предоставляет действительно удобные и рабочие инструменты для этого.

План действий

  • настроим CI в GitHub Actions для небольшого проекта на PHP

  • научимся запускать тесты в матрице с покрытием (зачем это нужно также расскажу)

  • создадим ботов, которые будут назначать ревьюющих / исполнителей, выставлять метки для PR-s (на основе измененных файлов), а по окончании ревью и проверок в Check Suite будут автоматом мержить наши PR, а сами ветки будут удаляться автоматически.

  • подключим бота, который будет создавать релизы, которые автоматически будут пушиться в packagist.

В общем, мы постараемся минимизировать ручной труд так, чтобы от вас, как от автора вашего Open-Source пакета, оставалось только писать код, ревьюить и апрувить пулл-реквесты, а все остальное за вас делали боты. А если вы умеете делегировать, то и ревью и написание кода можно также возложить на плечи ваших соратников, проект будет и развиваться без вашего участия.

Настройка CI

Сильно углубляться в тонкости настройки CI для запуска тестов я не буду, на хабре достаточно постов об этом, но для небольшого проекта на PHP с базой данных Postgres примера моего CI вполне хватит. Лишнее можно удалить, названия и ключи можно менять на ваш вкус.

Создайте файл примерно с таким содержимым:

.github/workflows/ci.yml
name: CIon:  push:    branches:      - master  pull_request:    types:      - opened      - reopened      - edited      - synchronizeenv:  COVERAGE: '1'  php_extensions: 'pdo, pdo_pgsql, pcntl, pcov, ...'  key: cache-v0.1  DB_USER: 'postgres'  DB_NAME: 'testing'  DB_PASSWORD: 'postgres'  DB_HOST: '127.0.0.1'jobs:  lint:    runs-on: '${{ matrix.operating_system }}'    timeout-minutes: 20    strategy:      matrix:        operating_system: ['ubuntu-latest']        php_versions: ['7.4']      fail-fast: false    env:      PHP_CS_FIXER_FUTURE_MODE: '0'    name: 'Lint PHP'    steps:      - name: 'Checkout'        uses: actions/checkout@v2      - name: 'Setup cache environment'        id: cache-env        uses: shivammathur/cache-extensions@v1        with:          php-version: '${{ matrix.php_versions }}'          extensions: '${{ env.php_extensions }}'          key: '${{ env.key }}'      - name: 'Cache extensions'        uses: actions/cache@v1        with:          path: '${{ steps.cache-env.outputs.dir }}'          key: '${{ steps.cache-env.outputs.key }}'          restore-keys: '${{ steps.cache-env.outputs.key }}'      - name: 'Setup PHP'        uses: shivammathur/setup-php@v2        with:          php-version: ${{ matrix.php_versions }}          extensions: '${{ env.php_extensions }}'          ini-values: memory_limit=-1          tools: pecl, composer          coverage: none      - name: 'Setup problem matchers for PHP (aka PHP error logs)'        run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"'      - name: 'Setup problem matchers for PHPUnit'        run: 'echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"'      - name: 'Install PHP dependencies with Composer'        run: composer install --prefer-dist --no-progress --no-suggest --optimize-autoloader        working-directory: './'      - name: 'Linting PHP source files'        run: 'composer lint'  test:    strategy:      fail-fast: false      matrix:        operating_system: ['ubuntu-latest']        postgres: [11, 12]        php_versions: ['7.3', '7.4', '8.0']        experimental: false        include:          - operating_system: ubuntu-latest            postgres: '13'            php_versions: '8.0'            experimental: true       runs-on: '${{ matrix.operating_system }}'    services:      postgres:        image: 'postgres:${{ matrix.postgres }}'        env:          POSTGRES_USER: ${{ env.DB_USER }}          POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }}          POSTGRES_DB: ${{ env.DB_NAME }}        ports:          - 5432:5432        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5    name: 'Test / PHP ${{ matrix.php_versions }} / Postgres ${{ matrix.postgres }}'    needs:      - lint    steps:      - name: 'Checkout'        uses: actions/checkout@v2        with:          fetch-depth: 1      - name: 'Install postgres client'        run: |          sudo apt-get update -y          sudo apt-get install -y libpq-dev postgresql-client      - name: 'Setup cache environment'        id: cache-env        uses: shivammathur/cache-extensions@v1        with:          php-version: ${{ matrix.php_versions }}          extensions: ${{ env.php_extensions }}          key: '${{ env.key }}'      - name: 'Cache extensions'        uses: actions/cache@v1        with:          path: '${{ steps.cache-env.outputs.dir }}'          key: '${{ steps.cache-env.outputs.key }}'          restore-keys: '${{ steps.cache-env.outputs.key }}'      - name: 'Setup PHP'        uses: shivammathur/setup-php@v2        with:          php-version: ${{ matrix.php_versions }}          extensions: ${{ env.php_extensions }}          ini-values: 'pcov.directory=src, date.timezone=UTC, upload_max_filesize=20M, post_max_size=20M, memory_limit=512M, short_open_tag=Off'          coverage: pcov          tools: 'phpunit'      - name: 'Install PHP dependencies with Composer'        run: composer install --prefer-dist --no-progress --no-suggest --optimize-autoloader        working-directory: './'      - name: 'Run Unit Tests with PHPUnit'        continue-on-error: ${{ matrix.experimental }}        run: |          sed -e "s/\${USERNAME}/${{ env.DB_USER }}/" \              -e "s/\${PASSWORD}/${{ env.DB_PASSWORD }}/" \              -e "s/\${DATABASE}/${{ env.DB_NAME }}/" \              -e "s/\${HOST}/${{ env.DB_HOST }}/" \              phpunit.xml.dist > phpunit.xml          ./vendor/bin/phpunit \            --verbose \            --stderr \            --coverage-clover build/logs/clover.xml        working-directory: './'      - name: 'Upload coverage results to Coveralls'        if: ${{ !matrix.experimental }}        env:          COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}          COVERALLS_PARALLEL: true          COVERALLS_FLAG_NAME: php-${{ matrix.php_versions }}-postgres-${{ matrix.postgres }}        run: |          ./vendor/bin/php-coveralls \            --coverage_clover=build/logs/clover.xml \            -v  coverage:    needs: test    runs-on: ubuntu-latest    name: "Code coverage"    steps:      - name: 'Coveralls Finished'        uses: coverallsapp/github-action@v1.1.2        with:          github-token: ${{ secrets.GITHUB_TOKEN }}          parallel-finished: true

Расскажу лишь в кратце, в этом конфиге 3 основных шага (lint, tests и coverage)

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

Если код не соответствует code style проекта, джобка падает и CI дальше не запускается. В данном примере, я использую линтер с правилами от umbrellio/code-style-php, а сами скрипты запуска описаны так (первый для проверки, второй для авто фиксов для локального использования):

"scripts": {   "lint": "ecs check --config=ecs.yml .",   "lint-fix": "ecs check --config=ecs.yml . --fix"}

test - тестируем наше приложение в матрице следующего ПО (os, postgres и php), а через опцию include добавляем что-то дополнительное (важно сохранить структуру ключей матрицы).

В целом тут тоже ничего нет сложного, разве что два момента:

  • опция experimental (к слову назвать опцию можно как угодно) для матрицы нужна для того, чтобы падающие джобки в экспериментальном окружении не фейлили CI, например, когда вы добавляете поддержку новой версии PHP, и не ставите самоцелью решать упавшие тесты или покрытие прям щас. Такие джобки игнорируются (если падают).

  • строки с sed -e "s/\${USERNAME}/${{ env.DB_USER }}/"... нужны для того, чтобы переменные подключения к БД были записаны из файла phpunit.xml.dist с плейсхолдерами в phpunit.xml, это не панацея, вы можете использовать переменные окружения ENV, но на всякий случай файл доступен тут:

phpunit.xml.dist
<?xml version="1.0" encoding="UTF-8"?><phpunit xmlns:xsi="http://personeltest.ru/away/www.w3.org/2001/XMLSchema-instance"         bootstrap="vendor/autoload.php"         colors="true"         convertErrorsToExceptions="true"         convertNoticesToExceptions="true"         convertWarningsToExceptions="true"         processIsolation="false"         stopOnFailure="false"         xsi:noNamespaceSchemaLocation="http://personeltest.ru/aways/schema.phpunit.de/9.3/phpunit.xsd">    <php>        <env name="APP_ENV" value="testing"/>        <ini name="error_reporting" value="-1" />        <var name="db_type" value="pdo_pgsql"/>        <var name="db_host" value="${HOST}" />        <var name="db_username" value="${USERNAME}" />        <var name="db_password" value="${PASSWORD}" />        <var name="db_database" value="${DATABASE}" />        <var name="db_port" value="5432"/>    </php>    <filter>        <whitelist processUncoveredFilesFromWhitelist="true">            <directory suffix=".php">./src</directory>            <exclude>                <file>./src/.meta.php</file>            </exclude>        </whitelist>    </filter>    <testsuites>        <testsuite name="Test suite">            <directory suffix="Test.php">./tests</directory>        </testsuite>    </testsuites></phpunit>

coverage - т.к. тестирование и покрытие также происходит в матрице, т.к. часть кода может быть написана под одну версию Postgres, а другая под другую и оформлено в виде условий в вашем коде, то покрыть на 100% за одну итерацию может быть невозможно. К сожалению, composer, в отличие от Bandler-а от Ruby, так делать не умеет.

Но т.к. я перфекционист и мне нужен badge:100% coverage, в моем случае используется матрица покрытия и затем, отправленные отчеты о покрытии, мержатся в один. Например, coveralls.io поддерживает обьединенный кавераж.

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

Авто-назначение меток (labels)

Для подключения бота создайте два файла (конфиг и скрипт):

.github/labeler.config.yml
type:build:  - ".github/**/*"  - ".coveralls.yml"  - ".gitignore"  - "ecs.yml"  - "phpcs.xml"dependencies:  - "composer.json"  - "composer.lock"type:common  - "src/**/*"type:tests:  - 'tests/**/*'  - 'phpunit.xml.dist'  - 'tests.sh'theme:docs:  - "README.md"  - "LICENSE"  - "CONTRIBUTING.md"  - "CODE_OF_CONDUCT.md"

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

Метки нужны для того, чтобы в последствии мы могли на их основании генерировать Summary для наших релизов и определять степень важности PR (будет ли это patch, minor или major). Вообще говоря, метки помогают визуально категоризировать пулл-реквесты, что очень удобно, когда их (pull-реквестов) много.

.github/workflows/labeler.yml
name: "Auto labeling for a pull request"on:  - pull_request_targetjobs:  triage:    name: "Checking for labels"    runs-on: ubuntu-latest    steps:      - uses: actions/labeler@main        with:          repo-token: "${{ secrets.GITHUB_TOKEN }}"          sync-labels: true          configuration-path: ".github/labeler.config.yml"

Авто-назначение ревьюеров и исполнителей

Для подключения бота создайте два файла (конфиг и скрипт):

.github/assignee.config.yml
addReviewers: truenumberOfReviewers: 1reviewers: - pvsaitpeaddAssignees: trueassignees: - pvsaintpenumberOfAssignees: 1skipKeywords:  - wip  - draft

Тут мы по сути описываем, кто и в каком кол-ве будет назначаться в качестве ревьюеров, и просто перечисляем логины пользователей с GitHub.

.github/workflows/assignee.yml
name: 'Auto assign assignees or reviewers'on: pull_requestjobs:  add-reviews:    name: "Auto assignment of a assignee"    runs-on: ubuntu-latest    steps:      - uses: kentaro-m/auto-assign-action@v1.1.2        with:          configuration-path: ".github/assignee.config.yml"

Авто-мержирование проверенных PR

Для подключения бота создайте файл скрипта с содержимым:

.github/workflows/auto_merge.yml
name: 'Auto merge of approved pull requests with passed checks'on:  pull_request:    types:      - labeled      - unlabeled      - synchronize      - opened      - edited      - ready_for_review      - reopened      - unlocked  pull_request_review:    types:      - submitted  check_suite:    types:      - completed  status: {}jobs:  automerge:    runs-on: ubuntu-latest    steps:      - name: 'Automerge PR'        uses: "pascalgn/automerge-action@v0.12.0"        env:          GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"          MERGE_METHOD: 'squash'          MERGE_LABELS: "approved,!work in progress"          MERGE_REMOVE_LABELS: "approved"          MERGE_COMMIT_MESSAGE: "pull-request-description"          MERGE_RETRIES: "6"          MERGE_RETRY_SLEEP: "10000"          UPDATE_LABELS: ""          UPDATE_METHOD: "rebase"          MERGE_DELETE_BRANCH: false

Из важного тут только то, что мержится будут только те PR, у которых будет выставлена метка approved, а также если все проверки в CheckSuite будут пройдены.

Мержить будем через Squash, чтобы была красивая история коммитов.

Авто-апрув отревьюенных PR

Когда ревьюющий ставит аппрув в PR, будем автоматом проставлять метку approved, создайте файл скрипта с содержимым:

.github/workflows/auto_approve.yml
on: pull_request_reviewname: 'Label approved pull requests'jobs:  labelWhenApproved:    name: 'Label when approved'    runs-on: ubuntu-latest    steps:      - name: 'Label when approved'        uses: pullreminders/label-when-approved-action@master        env:          APPROVALS: "1"          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}          ADD_LABEL: "approved"          REMOVE_LABEL: "awaiting review"

Авто-выпуск релизов с ченджлогом

Для подключения бота создайте два файла (конфиг и скрипт) с содержимым:

.github/release-drafter.yml
template: |  ## Changes  $CHANGESchange-template: '- **$TITLE** (#$NUMBER)'version-template: "$MAJOR.$MINOR.$PATCH"name-template: '$RESOLVED_VERSION'tag-template: '$RESOLVED_VERSION'categories:  - title: 'Features'    labels:      - 'feature'      - 'type:common'  - title: 'Bug Fixes'    labels:      - 'fix'      - 'bugfix'      - 'bug'      - 'hotfix'      - 'dependencies'  - title: 'Maintenance'    labels:      - 'type:build'      - 'refactoring'      - 'theme:docs'      - 'type:tests'change-title-escapes: '\<*_&'version-resolver:  major:    labels:      - major      - refactoring  minor:    labels:      - feature      - minor      - type:common  patch:    labels:      - patch      - type:build      - bug      - bugfix      - hotfix      - fix      - theme:docs      - type:tests  default: patch

В зависимости от меток, бот будет увеличивать либо MAJOR, либо MINOR, либо версию PATCH

.github/workflows/release_drafter.yml
name: Release Drafteron:  push:    branches:      - masterjobs:  update_release_draft:    runs-on: ubuntu-latest    steps:      - uses: release-drafter/release-drafter@v5        with:          publish: true        env:          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Теперь нужно провести некоторые настройки в GitHub Settings вашего проекта

Настройка Check Suite в GitHub

По умолчанию ветки в GitHub никак не ограничены, и пушить в них может каждый, кто имеет доступ на запись, но если вы хотите, чтобы код был красивый, чтобы код был покрыт на 100%, и у вас есть прочие хотелки, необходимо поставить ограничения и настроить Check Suite.

Пример, где настраиваются ограничения веток

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

Настройка approvals

По сути, тут мы настраиваем кол-во людей, которые должны посмотреть PR, будут ли сбрасываться апрувы, после появления новых коммитов, а также необходимо ли участие Code Owners в ревью.

Пример, как настраиваются approvalls

Настройка обязательных проверок для Check Suite

Все наши проверки (в CI это джобки, в основном, но и другие интеграции тоже, например, Coveralls / Scrutinizer, и прочие анализаторы кода), могут быть как обязательными или необязательными.

Если проверка обязательная, то мержирование PR будет заблокировано пока все проверки не будут пройдены.

Пример, как настроить Check Suite для ветки

Автоматически удаляем ветки после мержа

Чтобы у нас была красивая история коммитов, а также чтобы не удалять вручную ветки после мержа, в Settings => Options нужно разрешить только Squash, если вы хотите красивую историю коммитов и включить опцию "Automatically delete head branches"

Пример настройки тут

Настройка веб-хука для packagist.org

Тут все стандартно, на сайте packagist есть инструкция, но для полноты поста выложу тоже.

Пример, как настроить webhook packagist

Секретный ключ можно взять на packagist в настройках вашего профиля (Show Api Token).

Таким образом, если вы поддерживаете достаточное кол-во OpenSource проектов, и в каждом из них есть некоторое количество активных Contributor-ов (с правами записи), вы можете настроить CI так, что сообщество будет само писать код, а ваши доверенные лица будут ревьюить, общий workflow будет соблюден.

Вы даже можете в coveralls / scrutinizer настроить правила, чтобы Check Suite падал если % покрытия кода меньше 100%, а в Readme напичкать баджиками для красоты, например так:

Буду рад, если мой туториал будет кому-то полезен, т.к. перед написанием данного поста я впервые столкнулся с GitHub Actions, я не DevOps и настройкой CI не занимаюсь, самому пришлось прогуглить не один сайт, чтобы настроить такой workflow, который был нужен мне.

Подробнее..

Как документ на мобильнике распознается от простого к сложному

17.09.2020 10:15:44 | Автор: admin

Привет, хабр! С 2015-го года мы публикуем в нашем блоге посты, в которых описываем различные аспекты распознавания документов, удостоверяющих личность, (и не только) на мобильных устройствах (и не только). В комментариях нас несколько раз просили рассказать в целом о том, как устроена наша система распознавания, но мы долго откладывали, т.к. описать это в двух словах достаточно сложно.

В этой статье мы постарались кратко описать устройство нашей системы распознавания документов, удостоверяющих личность, и алгоритмов, которые применяются на различных этапах процесса. Если вы хотели узнать, как устроена система Smart IDReader, - добро пожаловать под кат!

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

Принципы

В первую очередь очертим требования, которые мы закладывали при проектировании системы. Четыре основных принципа, лежащих в основе Smart IDReader:

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

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

3. Автономность. Безопасность данных - превыше всего. Самый безопасный способ хранения данных - не хранить данные, самый безопасный способ передачи данных - не передавать данные. По нашему мнению, мобильная система распознавания обязана быть построена так, чтобы при распознавании никакие данные за пределы оперативной памяти конечного устройства не уходили. Следовательно, вся обработка изображений и все распознавание должно происходить непосредственно на устройстве, что накладывает дополнительные ограничения (см. п. 2) на используемые алгоритмы.

4. Масштабируемость. Наша первая система распознавания для мобильников (вот эта) поддерживала только внутренний паспорт РФ. При этом в мире существуют тысячи документов, удостоверяющих личность, и система должна быть устроена таким образом, чтобы добавить поддержку нового документа можно было быстро. Кроме того, добавление нового типа документа не должно требовать большого количества примеров, поскольку их неоткуда взять.

Трудности

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

Пожалуй, самым важным отличием мобильной системы распознавания является спектр возможных геометрических положений документа. На плоских сканах небольшой документ может быть сдвинут или повернут (тем самым, геометрические преобразования документа ограничены группой движения), тогда как при съемке мобильной камерой, документ может быть повернут относительно оптический системы на любой из трех Эйлеровых углов. Причем иногда такой поворот делается пользователем сознательно - к примеру, для того, чтобы избежать блика. Это расширяет группу ожидаемых геометрических искажений до проективной.

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

Итак, держим в уме: всё должно быть сделано точно и быстро, только средствами мобильного устройства, при этом надо учитывать неконтролируемые условия съемки, но можно использовать несколько кадров.

Поиск документа

Система Smart IDReader в её современном состоянии поддерживает большое количество разнообразных документов, но при её проектировании главным образом рассматривались документы, обладающие жесткой структурой. Грубо говоря, документом (точнее, страницей документа) с жёсткой структурой является документ, в котором все статические элементы совпадают на просвет - если стереть с двух разных экземпляров документа все реквизиты, то полученные бланки будут совпадать.

На первом этапе обработки изображения системой Smart IDReader мы детектируем страницы документа (мы их условно называем шаблонами). Шаблоны документов, которые мы ищем, нам заранее известны, все они представляются как изначально плоские прямоугольники (с известными физическими размерами), однако на изображении они могут быть проективно искажены. Кроме того, у одного документа могут быть несколько шаблонов: к примеру, передняя и оборотная стороны ID-карточки, или две страницы паспорта.

Для поиска шаблонов на изображении мы используем (иногда совместно) три основных подхода:

1. Быстрый поиск проективно искаженного прямоугольного объекта с известными соотношениями сторон. Метод основан на предварительном поиске прямых линий быстрым преобразованием Хафа и аккуратной переборной схеме. Про этот метод мы уже писали на Хабре, также про него можно почитать в этом докладе.

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

3. Наиболее общий подход, основанный на связке: особые точки + дескрипторы + RANSAC. Вначале на входном изображении производится поиск особых точек (мы используем модификацию особых точек YAPE для изображений с большим разбросом локального контраста, про эту модификацию можно почитать в этом докладе) и для каждой точки вычисляется локальный дескриптор (в нашем случае - методом RFD, модифицированным с целью ускорения). Далее в индексе известных дескрипторов запуском серии поисков ближайших соседей выявляются шаблоны-кандидаты. После этого кандидаты верифицируются при помощи метода RANSAC с учетом геометрического расположения особых точек. Этот метод используется в Smart IDReader для поиска и типизации подавляющего числа типов идентификационных документов. Подробнее про него можно почитать в этом докладе.

Поиск полей

Теперь, когда мы знаем координаты шаблона на изображении и точный тип этого шаблона (а следовательно, его соотношение сторон, настоящие физические размеры и т.п.), казалось бы, мы просто можем вырезать каждое нужное нам поле просто по его координатам? Так можно было бы делать, если бы на документах с жесткой структурой поля всегда находились в одних и тех же местах. Однако, к сожалению, из того, что на бланке документа напечатаны статические тексты (указывающие где должно быть какое-то поле) или даже пропечатаны линеечки, на которых эти поля должны находиться, еще не следует, что поля будут печататься одинаково. Как в России, так и за рубежом даже для жёстких документов качество печати документов оставляет желать лучшего и текстовые поля могут быть под углом, поверх статических текстов, поверх направляющих линеек и даже не совсем в тех местах, где нужно. Это означает, что нужно прибегать к специальным алгоритмам, предназначенным для поиска полей на найденном документе.

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

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

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

Подробнее про метод локализации текстовых полей на зоне с приблизительно определенным шаблоном расположения полей можно прочитать в этом докладе.

Обработка и распознавание полей

Теперь, когда мы наконец нашли текстовые поля, пришло время их распознавать, верно? Не совсем. Сначала задачу для распознавателя текста можно несколько облегчить, предварительно обработав изображения текстовых полей, исходя из априорных знаний о документе и о структуре распознаваемых полей. К примеру, зная, что на этом типе документа вот это конкретное поле содержится в ячеистой разграфке, или, если оно подчеркнуто, - прямые линии можно предварительно снять, используя морфологические операции. Или если мы знаем, что текстовые поля напечатаны курсивом, или если они рукописные, то предварительно можно исправить их наклон, используя поиск угла наклона букв при помощи быстрого преобразования Хафа (про это можно почитать здесь). При предварительной обработке полей зачастую рассматривается не каждое поле в отдельности, а сразу группа полей, поскольку несколько полей в рамках одной зоны, как правило, наклонены на один и тот же угол, либо написаны от руки одним и тем же почерком.

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

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

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

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

Теперь, имея результаты распознавания текстовых полей документа на изображении, мы можем составить структуру результата, которую отдать пользователю. Однако это всего лишь результат распознавания одного кадра.

Использование нескольких кадров

При использовании последовательности кадров Smart IDReader предполагает, что в рамках одной сессии распознавания на разных кадрах видеопотока виден один и тот же документ (это не означает, однако, что один и тот же шаблон - к примеру, при съемке ID-карты в рамках одной сессии документ можно перевернуть, показав оборотную сторону). Аккумулирование информации с нескольких кадров может быть полезно почти на любом этапе процесса анализа документа - начиная с поиска документа (как для дополнительной фильтрации набора возможных кандидатов, так и для уточнения геометрического позиционирования), и заканчивая распознаванием конкретного поля.

В системе Smart IDReader используется подход комбинирования результатов распознавания текстовый полей с нескольких кадров, что позволяет значительно повысить точность распознавания в видеопотоке и использовать даже информацию о частичном распознавании поля (если, к примеру, на каком-то кадре поле было прикрыто бликом). О методах комбинирования результатов распознавания текстовых строк можно прочитать, например, в этой статье.

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

Последним по порядку, но не по значению, аспектом работы с видеопотоком является анализ голограмм и других OVD (Optical Variable Devices) - элементов защиты документов, которые меняют свой внешний вид при изменении освещения или угла обзора. Для того, чтобы их детектировать и верифицировать их наличие, последовательность кадров просто необходима по определению. Рассматривая только одно изображение, верифицировать наличие такого элемента защиты нельзя.

Детали, детали

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

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

Спасибо вам за внимание! Если вам интересно подробнее узнать о каких-то специфических компонентах нашей системы распознавания, пишите нам в комментариях!

Подробнее..

Онбординг нового разработчика с помощью Ansible

29.01.2021 14:10:10 | Автор: admin

Ваш новый разработчик только что закончил подписывать трудовой договор и с горящими глазами готов закрывать по 15 задач в день. На его пути стоит лишь одно препятствие новый ноутбук, который пока что не настроен должным образом. Чаще всего процесс настройки окружения описывается в документе, который выдается новому разработчику. Мы не сильно далеко ушли и тоже составили такой список.

  1. Установка Xcode

  2. Настройка локального git репозитория

  3. Настройка окружения

  4. Настройка проекта

  5. Ознакомление с документацией

  6. Настройка таск-трекера (Jira/Youtrack)

Чтобы не тратить драгоценное время разработчика на ручную настройку нового ноутбука, мы автоматизировали 3 и 4 пункты, т.к. они являются самыми трудозатратными.

Итак, давайте сперва рассмотрим настройку окружения.

Настройка окружения

В нашем случае настройка окружения состоит из нескольких пунктов:

1. Установка brew зависимостей

2. Установка mint зависимостей

3. Установка и настройка ruby

4. Установка и настройка python

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

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

В конечном итоге скрипт получился вот таким:

#!/usr/bin/env bashcurl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && sudo python get-pip.pysudo pip install ansiblecd $(dirname $0) && ansible-playbook main.yml

Тут происходит следующее:

  1. Установка менеджера пакетов pip

  2. Установка Ansible

  3. Запуск наших задач из main.yml

Осталось разобраться, что находится в main.yml. А там всего лишь

- hosts: localhost  roles:- common_setup

Здесь hosts отвечает за те машины, на которых мы хотим запустить наши задачи, в нашем случае это локальная машина, а roles содержит информацию о том, какие задачи нужно выполнить, переменные и метаинформацию.

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

Каждая папка (meta, tasks и vars) должна содержать файл main.yml.

Все команды можно уместить в главном main.yml, но подход через roles обеспечивает большую читаемость и модульность.

Больше всего нас интересует папка tasks, в которой собраны задачи, выполняющие настройку. Рассмотрим структуру задачи, которая выполняет установку и настройку Ruby с помощью rvm.

---- name: Check if RVM already installedstat: path=~/.rvmregister: rvmdirchangedwhen: false- name: Install RVM for a usercommand: bash -c "\curl -sSL https://get.rvm.io | bash -s -- --ignore-dotfiles"when: rvmdir.stat.exists == False- name: Add RVM to profilecommand: bash -c "~/.rvm/bin/rvm get stable --auto-dotfiles"when: rvmdir.stat.exists == False- name: Add RVM to zshrccommand: bash -c "echo '\n[ -s ${HOME}/.rvm/scripts/rvm ] && source ${HOME}/.rvm/scripts/rvm' >> ~/.zshrc"when: rvmdir.stat.exists == False- name: Install {{ rubyversion }}command: bash -c "~/.rvm/bin/rvm install {{ rubyversion }}"when: rvmdir.stat.exists == False- name: Set Ruby Defaultcommand: bash -c "~/.rvm/bin/rvm --default use {{ rubyversion }}"when: rvmdir.stat.exists == False- name: Install Ruby Gems required for iOS app developementgem: name={{ item.name }} version={{ item.version }} state={{ if item.version is defined nil else latest }}withitems: "{{ rubygems_packages_to_install }}"when: rvmdir.stat.exists == False
  1. Проверяем, установлен ли rvm.

    name - название задачи

    stat - путь до rvm

    register сохраняет переменную для последующего использования

    changed_when переопределяет поведение смены состояния машины после исполнения

    Мы точно знаем, что после исполнения этой задачи состояние не изменится, поэтому просто ставим false. Более подробно использование этой команды описано здесь.

  2. Устанавливаем rvm.

    name - название задачи

    command - команда для выполнения

    when - условие для выполнения

    Условием для выполнения здесь является отсутствие установленного ранее rvm, мы можем узнать это из сохраненной на предыдущем шаге переменной rvmdir.

  3. Настраиваем rvm. Параметры аналогичны.

  4. Добавляем строки в .zshrc конфиг. На MacOS Catalina этот шаг обязателен, так как zsh теперь используется по умолчанию.

  5. Устанавливаем нужную нам для разработки версию Ruby. Тут параметры тоже аналогичны, единственным отличием будет использование нашей переменной из папки vars. Объявлена она в main.yml следующим образом:

    rubyversion: ruby-2.6.5

  6. Назначаем свежеустановленную версию Ruby версией по умолчанию

  7. Устанавливаем нужные гемы, в нашем случае это просто bundler. Тут также используется переменная из папки vars, объявленная таким образом:

    rubygems_packages_to_install:

    - name: bundler

    version:2.1.4

На этом настройка окружения закончена, можем переходить к настройке самого проекта.

Настройка проекта

Под настройкой проекта мы подразумеваем полную установку всех зависимостей и других необходимых вещей для успешного билда.

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

#!/usr/bin/rubyrequire 'FileUtils'require 'colorize'RESOURCES_DIRECTORY = './Scripts/Resources'.freezeXCODE_DIRECTORY = '~/Library/Developer/Xcode'.freezedef setup_git_hooks  puts "Setting up git hooks".blue.bold  git_hooks_path = '.git/hooks'  FileUtils.mkdir_p(git_hooks_path)  Dir["#{RESOURCES_DIRECTORY}/Git hooks/*"].each do |file|FileUtils.cp_r(file, "#{git_hooks_path}/#{File.basename(file)}")  endenddef setup_file_templates  puts "\nSetting up file templates".blue.bold  file_templates_path = File.expand_path("#{XCODE_DIRECTORY}/Templates/File Templates/Tests")  FileUtils.mkdir_p(file_templates_path)  Dir["#{RESOURCES_DIRECTORY}/Templates/*.xctemplate"].each do |file|FileUtils.cp_r(file, "#{file_templates_path}/#{File.basename(file)}")  endenddef setup_xcode_snippets  puts "\nSetting up xcode snippets".blue.bold  need_to_reboot_xcode = false  code_snippets_path = File.expand_path("#{XCODE_DIRECTORY}/UserData/CodeSnippets")  FileUtils.mkdir_p(code_snippets_path)  Dir["#{RESOURCES_DIRECTORY}/Snippets/*.codesnippet"].each { |file|path = "#{code_snippets_path}/#{File.basename(file)}"next if File.file?(path)need_to_reboot_xcode = trueFileUtils.cp_r(file, path)  }  return unless need_to_reboot_xcode  puts 'Quiting Xcode'.blue  system('killall Xcode')enddef setup_gems  puts "\nSetting up gems".blue.bold  system('bundle install')enddef setup_mocks  puts "\nSetting up mocks".blue.bold  system('cd $(pwd) && swiftymocky generate')enddef setup_projects  puts "\nSetting up projects".blue.bold  system('bundle exec rake update_projects')end# StepsDir.chdir("#{File.expand_path(File.dirname(__FILE__))}/..")setup_git_hookssetup_file_templatessetup_xcode_snippetssetup_gemssetup_mockssetup_projects

Что тут вообще происходит:

  1. Настройка гит-хуков. В нашем случае к коммиту всегда прибавляется номер задачи, который берется из названия ветки.

  2. Установка темплейтов для генерации модулей.

  3. Установка полезных сниппетов.

  4. Установка гемов.

  5. Генерация моков.

  6. Настройка проекта.

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

Заключение

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

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

Подробнее..

Как мы контролируем качество моделей для детектирования объектов на изображениях

08.07.2020 18:05:40 | Автор: admin
image

Добрый день. Нас зовут Татьяна Воронова и Эльвира Дяминова, мы занимаемся анализом данных в компании Center 2M. В частности, мы обучаем нейросетевые модели для детектирования объектов на изображениях: людей, спецтехники, животных.

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

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

Один из наших проектов подсчет людей в очередях. Из-за того, что заказчик крупная компания с большим количеством отделений, люди в определенные часы скапливаются как по расписанию, то есть регулярно происходит детектирование большого количества объектов (голов людей). Поэтому мы решили начать внедрение автоматического дообучения именно на этой задаче.

Вот как выглядел наш план. Все пункты, кроме работ разметчика, осуществляются в автоматическом режиме:

  1. Раз в месяц выбираются автоматически все изображения с камер за последнюю неделю.
  2. Названия изображений добавляются на общую xls-страницу в sharepoint, а также заносятся статусы файлов-изображений по умолчанию: Не просмотрено.
  3. С помощью последнего (работающего в текущий момент) варианта модели генерируется разметка изображений в папку также добавляются xml-файлы с разметкой (координаты найденных голов), а на страницу автоматически заносится общее количество найденных моделью объектов это число понадобится в дальнейшем для отслеживания качества модели.
  4. Разметчики раз в месяц просматривают размеченные файлы в статусе Не просмотрено. Правят разметку и заносят количество исправлений в xls-страницу (отдельно количество удаленных меток, отдельно количество добавленных). Статусы просмотренных разметчиком файлов меняются на Просмотрено. Таким образом, мы понимаем, как деградировало качество нашей модели.

    Кроме того, мы уясняем характер ошибки: размечается ли в основном лишнее (сумки, стулья) или, наоборот, не находим часть людей (например, из-за медицинских масок). График изменяющихся метрик качества модели выводится в виде панели-отчета.
  5. Раз в месяц по xls-файлу смотрится количество файлов в статусе Просмотрено и количество изменений > 0. Если количество выше порогового значения, запускается переобучение модели на расширенном множестве (с добавлением поправленной разметки). Если ранее файл входил в обучающий датасет, старая разметка по файлу меняется на новую. У файлов, взятых в обучение, статус меняется на Взято в обучение. Статус нужно менять, иначе одни и те же файлы будут повторно попадать в дообучение. Дообучение производится начиная с чекпоинта, оставшегося при предыдущем обучении. В дальнейшем мы планируем вводить дообучение не только по расписанию, но и превышению порога количества изменений, которые пришлось сделать в разметке.
  6. Если количество файлов в статусе Просмотрено равно 0, необходимо оповещение разметчик по какой-то причине не проверяет разметку.
  7. Если, несмотря на дообучение модели, точность продолжает падать, а метрики спускаются ниже порогового значения, необходимо оповещение. Это знак, что нужно детально разбираться в проблеме с привлечением аналитиков.

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

Например, на изображении ниже одно из отделений (в зимний день), кадры которого не были представлены в обучающем датасете:

image

Если посчитать метрики для этого кадра (TP = 25, FN = 3, FP = 0), то получится, что полнота (recall) 89%, точность (precision) 100%, а гармоническое среднее между точностью и полнотой около 94,2% (о метриках чуть ниже). Достаточно неплохой результат для нового помещения.

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

image

Леди вблизи:

image

При тестировании модели выбирались кадры, не участвовавшие в обучении (датасет с различным количеством людей на кадре, в разных ракурсах и различного размера), для оценки качества модели мы использовали recall и precision.

Recall полнота показывает, какую долю объектов, реально относящихся к положительному классу, мы предсказали верно.

Precision точность показывает, какую долю объектов, распознанных как объекты положительного класса, мы предсказали верно.

Когда заказчику была необходима одна цифра, комбинация точности и полноты, мы предоставляли гармоническое среднее, или F-меру. Подробнее про метрики.

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

image

Полнота в 80% до каких-либо изменений связана с тем, что к системе добавилось большое количество новых отделений, появились новые ракурсы. К тому же сменилось время года, до этого в обучающем датасете были представлены осенне-зимние люди.

После первого цикла полнота стала 96,7%. Если сравнивать с первой статьей, то там полнота достигала 90%. Такие изменения связаны с тем, что сейчас количество людей в отделениях снизилось, они стали намного меньше перекрывать друг друга (закончились объемные пуховики), да и разнообразие головных уборов поубавилось.

Например, раньше нормой было примерно такое количество людей, как на изображении ниже.

image

Сейчас дела обстоят таким образом.

image

Подводя итоги, назовем плюсы автоматизации:

  1. Частичная автоматизация процесса разметки.
  2. Своевременное реагирование на новые ситуации (поголовное ношение медицинских масок).
  3. Быстрое реагирование на неправильные ответы модели (сумка стала детектироваться как голова и тому подобные случаи).
  4. Мониторинг точности модели на постоянной основе. При изменении метрик в худшую сторону подключается аналитик.
  5. Минимизация трудозатрат аналитика при дообучении модели. Наши аналитики занимаются разными проектами с полным вовлечением, поэтому хотелось бы как можно реже отрывать их от основного проекта для сбора данных и дообучения по другому проекту.

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

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

Авторы статьи: Татьяна Воронова (tvoronova), Эльвира Дяминова(elviraa)
Подробнее..

Распознавание документов на частном примере обзор доступных платных и бесплатных решений

10.09.2020 14:18:39 | Автор: admin
Всем привет! Типичная ситуация сложилась в компании, в которой я работаю. В бухгалтерии вечный аврал, людей не хватает, все занимаются чем-то безусловно важным, но по сути бесполезным. Такое положение дел не устраивало руководство.

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

Так передо мной, как аналитиком, встала задача: найти решение для распознавания документа типичного для моей компании счет-фактуры структурировать его в имеющиеся хранилища, а также в 1С. Решение, которое будет удобным, понятным, и не влетит компании в копеечку.

Опыт получился занятным, решил поделиться тем, что удалось собрать. Возможно я что-то упустил, поэтому велком в комментарии, если есть, что добавить.


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

Начал я с бесплатных программ:


  • glmageReader
  • Paperwork
  • VietOCR
  • CuneiForm.

В ходе распознавания нашего счета-фактуры такими программами я увидел следующее:

  • В таких программах как VietOCR, Paperwork, glmageReader можно настроить хранение отсканированных документов в определенные папки, Paperwork умеет их даже сортировать, согласно меткам.
  • В основном они хорошо справляются с текстом, а там, где текст распознан некорректно, в некоторых программах можно вручную изменить содержимое, прежде чем экспортировать файл.


image

Однако есть и проблемы:

  • Есть разница между работой с pdf сканами и png. Не всегда удается удачно конвертировать png в pdf.
  • Большинство таких программ сложно справляются с распознаванием документов табличного вида, даже самого простого формата. В результате мы получаем распознанный текст без размеченных полей.

    image
  • Иногда неточно определяется шрифт, вследствие чего при конвертации весь распознанный текст наезжает друг на друга.
  • В процессе распознавания иногда необходимо делать выравнивание по ключевым словам, с доворотами и смещением координат.
  • В некоторых программах таблица распознавалась как картинка и экспортировалась в новый документ Word тоже в качестве картинки, очень урезанной, которую даже сложно разглядеть.
  • При редактировании распознанного содержимого в некоторых программах возникали проблемы, менялся шрифт или сам текст.


image

Технология сработала достаточно хорошо, Учитывая, что программы бесплатные, описанные выше проблемы допустимы. Однако, я искал более упорядоченного решения.

Затем я исследовал распознавание в ABBYY FineReader 15 Corporate


За 7-дневный срок триала, я изучил и эту платформу.

Что отметил:

  • Когда я открыл png файл, он отлично был считан и в результате удачно конвертирован в pdf без потери качества изображения и текста.
  • Программа отлично знает, как отсканировать документ для редактирования текста. Причем в режиме редактирования файла формата png текст удается отредактировать без проблем, но иногда слетает разметка.
  • Однако то же самое я не могу сказать про редактирование файла-скана pdf. При попытке редактирования летели слои.
  • Табличный вид распознается качественно, вся структура сохраняется, меня это порадовало.
  • OCR редактор хорошо распознал мой сформированный pdf счет-фактуры. Где-то пару символов требовалось поправить вручную.

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

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

От использования этого софта были приятные впечатления. Однако, когда я обратился к ценнику системного решения ABBYY Flexicapture (а мне нужно именно системное), то выяснил, что решение, особенно кастомизированное, обходится в довольно круглую сумму, около 400 тыс. руб./мес. и выше за 10 тыс. страниц.

Я стал искать альтернативу. Как освободить руки сотрудника, получить качественное распознавание документов и не переживать за сохранность и структуру данных.

И тут я решил получше разглядеть ELMA RPA, которую я уже изучал ранее.


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

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

Здесь главная цель распознавание реквизитов документа и их передача в другие системы/сайты/приложения. Кроме того, роботы складывают всю информацию куда надо: автоматически находят нужные папки и сохраняют в необходимых форматах.

Какие виды распознавания в системе я посмотрел:

Распознавание по шаблону


Нам предлагается на основании шаблона документа распознать подгружаемый документ. Насколько мне известно, этот вид распознавания бесплатный, внутрь зашит движок Tesseract.

Что отметил:
  • Этот вид распознавания работает именно со сканами формата jpg и png, pdf он пока не рассматривает. Но продукт еще молодой, думаю, все впереди.
  • Этот вид распознавания входит в бесплатную версию Community Edition
  • Удобно размечен текст по блокам, которые можно сопоставить, согласно переменным, которые мы создали в контексте робота. Таким образом вручную настроить, что именно тянем в распознавание.
  • Нашу счет-фактуру он распознал 50/50, некоторые слова подменил как посчитал нужным. :)

    image


Однако, вендор на данный кейс сообщил, что этот вид распознавания адаптирован под простые документы, с текстовой структурой или с легкими формами. И посоветовал для распознавания счета-фактуры использовать другой вид распознавания intellect lab.

Процесс тот же, загружаем шаблон и по нему распознаем. Но здесь шаблон отправляется на облачный сервер.

От сервера получаем ответ (распознает такой тип документа или нет), и если распознается, то передается структура шаблона (переменные для маппинга), для сопоставления переменных, которые необходимо будет записать в RPA процессе.

В процессе воспроизведения мы отправляем уже документ, который хотели бы распознать и получаем ответ от iLab сервера о распознавании.

Что отметил по поводу этого распознавания:

  • Здесь уже распознавание работает как программа сканирования документов pdf, и при этом работает и с форматами jpg и png.
  • Качество документа не влияет на эффективность распознавания. Даже документы с плохим качеством распознаются корректно.
  • Счет-фактура распозналась полностью и без подмен переменных.
  • Робот сумел получить скан с почты, распознать его и создать его экземпляр в 1С. То есть автоматически сохранил файл там, где мы ему задали, что, естественно, крайне удобно.
  • Входит в бесплатную Community Edition в виде распознавания документа в облаке. Подходит, если используем стандартные типы (СФ, УПД, АВР и др.), и, например до 100 документов в месяц или до 500 в год. (Стоит заметить, что считаем не в страницах, а в документах непосредственно.)

Сам процесс распознавания документов довольно сложно отобразить на видео, так как это происходит в коробке, а экран пустует несколько секунд. Поэтому я сделал отдельную запись распознанных данных в блокнот для визуализации.

Распознавание документа в блокнот

Соответственно, эти же данные робот записывает в 1С, создавая там новый документ:

распознание документа и создание в 1С

Что удалось выяснить по ценам: Если мы, например, хотим работать масштабно именно с ilab распознаванием, то за наши 10 000 документов придется выложить:
  • примерно 180 000 руб. единовременно,
  • плюс, допустим, 400 000 руб. покупка робота с оркестратором
  • итого: 580 000 руб.

Робот бессрочный, а 10 000 документов на какое-то время хватит. Довольно выгодно получается, как минимум в том, что заплатим за все один раз.

Что понравилось в распознавании в этой платформе в целом:

  • Можно настроить получение документов по событию, а также, например из электронной почты и любых других внешних источников. У меня пока была цель настроить получение с почты.
  • Все считанные данные с документа можно спокойно записать в контекстные переменные и далее их передать в необходимые системы, приложения, сайты, ВМ и т д. И я не переписываю уже ничего руками.
  • Скорость обработки. 15 секунд и объект распознан, а остальной порядок действий это счет по минутам. Если заявиться с потоковым сканированием с большим количеством документов, думаю это не составит больших временных затрат.
  • Много качественного функционала в свободном доступе, для небольших компаний им можно вполне обойтись.


Итого:


  • Бесплатные программы справляются с задачей распознавания документов лучше, чем я предполагал, однако за счет них значительно ускорить работу с большим объемом не удастся
  • ABBYY FineReader хорошо справляется с обработкой и распознаванием документов после, однако, чтобы получить системное решение, нужны большие финансовые возможности.
  • ELMA RPA удивила по качеству распознавания документов, вариативностью, а также возможностям хранения и передачи после распознавания, но стоит учесть, что продукт молодой.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru