Обработка XML-данных в AW BI

Введение: что такое XML и зачем это нужно

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

XML (eXtensible Markup Language) - это язык разметки который хранит данные в древовидной структуре с помощью тегов. Появился в 1998 году и до сих пор активно используется в корпоративных системах, государственных сервисах, финансовых приложениях. У него есть ряд преимуществ и недостатков, один из которых – это некоторые сложности парсинга, подробнее можно прочитать про XML по ссылке.

Сейчас зачастую в задачах используются JSON (JavaScript Object Notation) - более современный формат, компактный и удобный для веб-приложений. В AW BI есть отдельный блок для работы с JSON. Сегодня же мы сфокусируемся на XML -формате который встречается в выгрузках из 1С, SAP, государственных информационных систем и много где еще.

В чём сложность работы с XML?

XML хранит данные в виде вложенных тегов - это удобно для передачи, но не всегда удобно для анализа.

Пример XML-фрагмента заказа:

<order>
  <items>
    <item sku="NB-DELL-001" category="Ноутбуки">
      <qty>3</qty>
      <price>89990</price>
    </item>
    <item sku="MS-LOG-002" category="Мыши">
      <qty>3</qty>
      <price>2490</price>
    </item>
  </items>
  <delivery>
    <method>courier</method>
    <city>Москва</city>
    <days>2</days>
  </delivery>
  <discount percent="5"/>
</order>

Задача: превратить эту структуру в аналитические поля модели: количество товаров, список SKU, сумму заказа, метод доставки и.т.д.

Инструменты: Python и библиотека для работы с XML

В AW BI уже встроена библиотека для работы с парсингом XML. А конкретнее нам сегодня пригодится ElementTree - это встроенный в Python парсер XML. Работает по принципу «дерева элементов» - каждый тег XML становится объектом с которым можно работать в коде.

Как работает ElementTree?

Представьте XML как папку с файлами на компьютере:
• Корневой тег — это главная папка
• Вложенные теги , — подпапки внутри
• Конечные значения 3 — это файлы с данными

ElementTree позволяет «открыть» эту структуру, найти нужные папки и файлы, прочитать их содержимое.

Источник данных: откуда берётся XML

На самом деле XML-данные могут поступать из разных источников:

• Колонка в таблице БД - выгрузка из ERP-системы хранит детали заказа в XML-поле
• API-ответы - внешний сервис возвращает данные в XML-формате
• Файлы - ежедневные выгрузки из учётных систем

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

Структура демо-данных

Файл orders_with_xml.csv содержит 10 заказов с такими колонками:
• order_id - номер заказа
• created_at - дата создания
• customer_id, customer_name - информация о клиенте
• manager - ответственный менеджер
• status - статус заказа (delivered, processing, cancelled)
• order_details - XML-строка с деталями заказа
Наша задача: развернуть колонку order_details в понятные аналитические поля — количество товаров, список артикулов, сумму, скидку, информацию о доставке.

Почему нужен ETL-блок «Декоратор»?

AW BI предлагает гибкие возможности для обработки данных через Python. Часть задач могут быть закрыты через написание ETL-скриптов и точки расширения системы (функции before_all, after_load_*, after_all), но в нашем случае нам пригодится особый инструмент – ETL-блок «Декоратор».

Блок «Декоратор» - это механизм который позволяет явно сообщить AW BI: «Я добавлю новые колонки, и вот какие они будут». Это своего рода контракт между вашим кодом и системой. В нем можно явно задать наименование функций, которые вы будете переопределять в скрипте модели. В нашем случае это будут get_schema() и get_data()
Декоратор работает в два этапа:

• get_schema() - сообщает AW BI список колонок которые появятся (вы их потом увидите в пользовательском интерфейсе модели)
• get_data() - реально создаёт эти колонки в данных. Правила получения наших данных

Подробнее о блоке и других возможностях ETL можно узнать в официальной документации AW BI.

Реализация

Теперь пройдём весь путь от загрузки файла до рабочей модели с развёрнутыми XML-данными.

1) Загрузка файла в AW BI

Создаём источник данных из CSV или Excel

Загрузите файл orders_with_xml.csv в AW BI через интерфейс создания файловых источников. Система автоматически определит структуру: 7 колонок, одна из которых (order_details) содержит XML-строку.

2) Создание модели
Добавляем источник в новую модель

Создайте новую модель и добавьте в неё загруженный источник. На этом этапе модель содержит только исходные колонки без раскрытого XML.

3) Добавление блока-декоратора
Оборачиваем источник в декоратор

В редакторе модели:
• Нажмите «Добавить объект» → «ETL-блок»
• Выберите блок «Декоратор»
• Перетащите источник orders_with_xml внутрь блока (drag-and-drop)

4) Настройка параметров блока
Указываем имена функций из ETL-скрипта

Кликните кнопкой на блок-декоратор → «Настроить параметры». Укажите:
• Функция для получения схемы: get_schema
• Функция для построения данных: get_data

⚠️ Пишите БЕЗ скобок () — это названия функций, а не их вызов!

5) Написание ETL-скрипта
Создаём код для парсинга XML

Откройте редактор ETL-скриптов и вставьте следующий код. Разберём его по частям:

Часть 1: Импорты и схема


# Подключаем необходимые библиотеки
import xml.etree.ElementTree as ET
from pyspark.sql import functions as F
from pyspark.sql.types import (
	StructType, StructField,
	StringType, IntegerType, DoubleType
)
 
# Описываем структуру того что вернёт парсер
PARSED_SCHEMA = StructType([
	StructField('items_count', IntegerType(), True),
	StructField('sku_list', StringType(), True),
	StructField('categories', StringType(), True),
	StructField('subtotal', DoubleType(), True),
	StructField('discount_percent', DoubleType(), True),
	StructField('discount_amount', DoubleType(), True),
	StructField('total_with_discount', DoubleType(), True),
	StructField('delivery_method', StringType(), True),
	StructField('delivery_city', StringType(), True),
	StructField('delivery_days', IntegerType(), True),
	StructField('parse_error', StringType(), True),
])

PARSED_SCHEMA - это карта того, что мы извлечём из XML. Каждое поле указывает имя колонки, тип данных и может ли значение быть пустым (nullable=True).

Часть 2: Вспомогательная функция парсинга XML

Функция parse_order_xml - ядро всего решения. Важно понимать что она выполняет три задачи одновременно:

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

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

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

Разберём работу функции пошагово:

def parse_order_xml(xml_string):
	# Заглушка на случай ошибок
	empty = {f.name: None for f in PARSED_SCHEMA.fields}
	
	# Проверка на пустое значение
	if not xml_string or not xml_string.strip():
    	empty['parse_error'] = 'empty_xml'
    	return empty
	
	# Разбор XML
	try:
    	root = ET.fromstring(xml_string.strip())
	except ET.ParseError as e:
    	empty['parse_error'] = f'xml_parse_error: {e}'
    	return empty
	
	# Извлечение данных
	try:
    	items = root.findall('./items/item')
    	sku_list = [str(i.get('sku', '')) for i in items]
    	categories = sorted({str(i.get('category', '')) for i in items if i.get('category')})
    	
    	# Подсчёт суммы
    	total = 0.0
    	for item in items:
        	qty_el = item.find('qty')
        	price_el = item.find('price')
        	qty = int(qty_el.text) if qty_el is not None and qty_el.text else 0
        	price = float(price_el.text) if price_el is not None and price_el.text else 0.0
        	total += qty * price
    	
    	# Скидка и доставка
    	disc_el = root.find('discount')
    	disc_pct = float(disc_el.get('percent', 0)) if disc_el is not None else 0.0
    	
    	delivery = root.find('delivery')
    	method = delivery.find('method').text if delivery is not None and delivery.find('method') is not None else None
    	city = delivery.find('city').text if delivery is not None and delivery.find('city') is not None else None
    	days_el = delivery.find('days') if delivery is not None else None
    	days = int(days_el.text) if days_el is not None and days_el.text else None
    	
    	return {
        	'items_count': len(items),
        	'sku_list': ', '.join(sku_list),
        	'categories': ', '.join(categories),
        	'subtotal': round(total, 2),
        	'discount_percent': disc_pct,
        	'discount_amount': round(total * disc_pct / 100, 2),
        	'total_with_discount': round(total * (1 - disc_pct / 100), 2),
        	'delivery_method': method,
        	'delivery_city': city,
        	'delivery_days': days,
        	'parse_error': None,
    	}
	except Exception as e:
    	empty['parse_error'] = f'runtime_error: {type(e).__name__}: {e}'
    	return empty

Функция parse_order_xml - сердце решения. Она получает XML-строку и возвращает словарь с разобранными данными.

Часть 3: Функции для блока-декоратора

def get_schema(schemas):
	# Берём схему источника
	source_schema = schemas[0]
	
	# Добавляем к ней поля из XML
	final_fields = list(source_schema.fields) + list(PARSED_SCHEMA.fields)
	return StructType(fields=final_fields)
 
 
def get_data(dfs, app):
	# Берём первый DataFrame
	df = dfs[0]
	
	# Регистрируем UDF (User Defined Function)
	parse_udf = F.udf(parse_order_xml, PARSED_SCHEMA)
	
	# Применяем к колонке order_details
	df = df.withColumn('_xml_parsed', parse_udf(F.col('order_details')))
	
	# Разворачиваем структуру в плоские колонки
	for field in PARSED_SCHEMA.fields:
    	df = df.withColumn(field.name, F.col(f'_xml_parsed.{field.name}'))
	
	# Удаляем промежуточную колонку
	df = df.drop('_xml_parsed')
	return df

get_schema() сообщает AW BI: вот список всех колонок которые будут в итоге. get_data() создаёт эти колонки реально - применяет функцию парсинга к каждой строке через механизм UDF (пользовательская функция в Spark).

6) Тестирование

Проверяем что всё работает

В редакторе ETL нажмите «Запуск на тестовых данных». Система на данном этапе загрузит не более 10 000 строк (в нашем случае наш файл небольшой, но этот факт стоит учитывать на боевых проектах) и выполнит скрипт. В результатах вы должны увидеть:

• Исходные 7 колонок из CSV
• Плюс 11 новых колонок из XML: items_count, sku_list, categories, subtotal, discount_percent, discount_amount, total_with_discount, delivery_method, delivery_city, delivery_days, parse_error
• В логе не должно быть ошибок

7) Публикация
Делаем скрипт активным

Если тест прошёл успешно - нажмите «Публикация скрипта» → «Опубликовать». Теперь скрипт будет работать при каждой синхронизации данных модели.

Адаптация под свои данные

Наш пример основан на конкретной структуре XML. Вот как адаптировать код под ваши данные:

1. Изучите структуру XML

Откройте несколько примеров XML из вашего источника. Найдите закономерности:
• Какие теги всегда присутствуют?
• Какие могут отсутствовать?
• Где хранятся ключевые данные - в атрибутах или в содержимом тегов?
• Есть ли вложенные списки (например, несколько товаров в заказе)?

2. Обновите PARSED_SCHEMA

Замените список полей на те что нужны вам. Примеры типов данных:
StringType() — текст (названия, коды, описания)
IntegerType() — целые числа (количество, дни, ID)
DoubleType() — дробные числа (цены, проценты, суммы)
BooleanType() — True/False (флаги)
DateType() — даты

3. Адаптируйте parse_order_xml

Измените логику извлечения данных, например:

# Для чтения атрибута тега:
sku = item.get('sku', '')
# Для чтения содержимого тега:
qty_el = item.find('qty')
qty = int(qty_el.text) if qty_el is not None and qty_el.text else 0
# Для поиска нескольких элементов:
items = root.findall('./items/item')
# Для поиска одного элемента:
delivery = root.find('delivery')

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

💡 Начните с малого - выберите 2-3 ключевых поля из XML. Сделайте их парсинг, протестируйте. Затем постепенно добавляйте остальные. Так легче отлаживать ошибки.

Заключение

Сегодня мы с вами намеренно вышли немного за рамки привычного подхода self-service инструмента. В AW BI вы не ограничены готовыми блоками, когда нужно решить нестандартную задачу, на помощь приходит Python с его библиотеками. Если стандартных деталей на схеме недостаточно, вы можете создать свой кастомный элемент через Python, который идеально встраивается в общую конструкцию модели. И эти принципы универсальны: определите схему данных, прописываете логику трансформации и интегрируете в общую логику модели.