Direct Pipeline: Импорт и миграция данных¶
Команда миграции данных¶
Для миграции данных со старого механизма (Item) на новый (Direct Pipeline) используется команда:
python manage.py migrate_to_direct_pipeline
Параметры команды¶
--type=<type_key>— мигрировать только уровни указанного типа (например,--type=products)--level=<level_key>— мигрировать только указанный уровень (например,--level=product)--dry-run— показать что будет сделано без выполнения
Что делает команда¶
- Синхронизация структуры:
- Создаёт/обновляет структуру
dim_*_directтаблиц (колонки, индексы) -
Создаёт/обновляет структуру
dim_*_fullтаблиц (колонки, индексы) -
Миграция контента:
- Синхронизирует данные из
Itemвdim_*_direct - Автоматически синхронизирует
dim_*_full(еслиauto_sync=True)
Примеры использования¶
# Миграция всех уровней
python manage.py migrate_to_direct_pipeline
# Миграция конкретного типа
python manage.py migrate_to_direct_pipeline --type=products
# Миграция конкретного уровня
python manage.py migrate_to_direct_pipeline --level=product
# Dry-run (показать что будет сделано)
python manage.py migrate_to_direct_pipeline --dry-run
Важные замечания¶
- Идемпотентность: Команда безопасна для повторного запуска
- Использует
ON CONFLICTдля обновления существующих записей -
Проверяет существование колонок перед добавлением
-
Порядок синхронизации: Уровни обрабатываются в правильном порядке (от родителей к детям)
-
Производительность: При большом количестве данных может занять время
- Рекомендуется запускать в фоне или через Celery
Импорт данных через Direct Pipeline¶
Обзор¶
Класс HierarchyImporter обеспечивает импорт иерархических данных в dim_*_direct таблицы с автоматической нормализацией родительских связей и запуском синхронизации.
Класс HierarchyImporter¶
Расположение¶
/src/planiqum/core/hierarchy/libs/direct/importer.py: HierarchyImporter
Основные методы¶
import_from_dataframe(level: Level, df: pd.DataFrame)¶
Импортирует данные из pandas DataFrame в dim_*_direct таблицу.
Параметры:
- level — уровень иерархии
- df — DataFrame с данными для импорта
Обязательные колонки:
- shortname — краткое имя элемента (обязательно!)
Опциональные колонки:
- description — описание элемента
- {parent_key} — shortname родительского элемента (например, brand, category)
- start_date, end_date, num — для календарных уровней
Пример:
from planiqum.core.hierarchy.libs.direct import HierarchyImporter
import pandas as pd
importer = HierarchyImporter()
# Импорт простых элементов
brand_df = pd.DataFrame([
['brand_a', 'Brand A'],
['brand_b', 'Brand B'],
], columns=['shortname', 'description'])
importer.import_from_dataframe(level=brand_level, df=brand_df)
# Импорт с родителями
product_df = pd.DataFrame([
['prod1', 'Product 1', 'brand_a'],
['prod2', 'Product 2', 'brand_b'],
], columns=['shortname', 'description', 'brand'])
importer.import_from_dataframe(level=product_level, df=product_df)
Режимы импорта¶
HierarchyImporter поддерживает различные режимы импорта для оптимизации производительности и гибкости работы с данными.
Параметры конструктора¶
HierarchyImporter(
on_missing_parent='set_null', # Поведение при отсутствии родителя
auto_sync=True, # Автоматическая синхронизация dim_*_full после импорта
sync_item_bridge=True, # Синхронизация с Item/core_hierarchy_item_parents (временный механизм)
relations_only=False, # Обновление только связей родителей
duplicate_strategy='last', # Стратегия выбора дубликатов ('first'/'last')
user=None, # Пользователь для логирования
comment='' # Комментарий к импорту
)
Параметры:
on_missing_parent: Поведение при отсутствии родителя'set_null'(по умолчанию): устанавливаем NULL для отсутствующих родителей-
'raise_error': выбрасываем ошибку и откатываем транзакцию -
auto_sync: Автоматически запускать синхронизациюdim_*_fullпосле импорта True(по умолчанию): автоматически вызываетсяDimTableFull.sync_structure()иDimTableFull.sync_content()-
False: синхронизацияdim_*_fullне выполняется автоматически -
sync_item_bridge: Синхронизировать сItem/core_hierarchy_item_parents(⚠️ временный механизм) True(по умолчанию): запускать синхронизацию ItemBridge через CeleryFalse: не запускать синхронизацию ItemBridge-
⚠️ Временный параметр — будет удалён после полного отказа от
Item -
relations_only: Обновлять только связи родителей, не трогая базовые поля элементов False(по умолчанию): полный UPSERT элементов + связей-
True: только UPDATE связей для существующих элементов -
duplicate_strategy: Стратегия обработки дубликатов 'last'(по умолчанию): при дубликатах оставляем последнюю запись-
'first': при дубликатах оставляем первую запись -
user: Пользователь-инициатор импорта (для журналирования) -
Если не указан, пытаемся получить текущего пользователя через middleware
-
comment: Комментарий к импорту (причина, контекст)
Автоматическое определение формата данных¶
Система автоматически определяет, нужно ли нормализовать данные, на основе переданных колонок:
Для основного уровня:
- Если передана колонка shortname (VARCHAR) → нормализация по shortname
- Если передана колонка id (BIGINT) → использование напрямую
- ⚠️ Критическая ошибка, если переданы обе колонки одновременно
Для родительских уровней:
- Если передана колонка {parent_key} (VARCHAR) → нормализация по shortname родителя
- Если передана колонка {parent_key}_id (BIGINT) → использование ID напрямую
- ⚠️ Критическая ошибка, если переданы обе колонки для одного родителя
Примеры:
# Пример 1: Нормализация по shortname (для основного уровня и родителей)
product_df = pd.DataFrame([
['prod1', 'Product 1', 'brand_a', 'cat_x'], # shortname + brand/category - shortnames
], columns=['shortname', 'description', 'brand', 'category'])
importer = HierarchyImporter()
importer.import_from_dataframe(level=product, df=product_df)
# Система автоматически нормализует brand и category
# Пример 2: Использование ID для родителей
brand_id = 12345 # ID из dim_brand_direct
product_df = pd.DataFrame([
['prod1', 'Product 1', brand_id], # shortname + brand_id (BIGINT)
], columns=['shortname', 'description', 'brand_id'])
importer = HierarchyImporter()
importer.import_from_dataframe(level=product, df=product_df)
# brand_id используется напрямую, нормализация не требуется
# Пример 3: Смешанный режим
product_df = pd.DataFrame([
['prod1', 'Product 1', 'brand_a', 999], # brand - shortname, category_id - ID
], columns=['shortname', 'description', 'brand', 'category_id'])
importer = HierarchyImporter()
importer.import_from_dataframe(level=product, df=product_df)
# brand нормализуется, category_id используется напрямую
relations_only (по умолчанию: False)¶
Определяет, обновлять ли только связи родителей без изменения базовых полей элементов.
relations_only=False (дефолт):
- Полный UPSERT элементов
- Создаёт новые элементы при необходимости
- Обновляет shortname, description и все связи
- Устанавливает update_item=TRUE и update_parents=TRUE
relations_only=True:
- Обновляет только связи родителей
- Не создаёт новые элементы
- Не обновляет базовые поля (description и др.)
- Устанавливает только update_parents=TRUE
- Используется для массового переназначения родителей
Пример:
# Создание/обновление элементов (дефолт)
product_df = pd.DataFrame([
['prod1', 'New Description', 'brand_b'],
], columns=['shortname', 'description', 'brand'])
importer = HierarchyImporter(relations_only=False)
importer.import_from_dataframe(level=product, df=product_df)
# Результат: description обновился, brand обновился
# Обновление только связей
product_df = pd.DataFrame([
['prod1', 'IGNORED Description', 'brand_c'],
], columns=['shortname', 'description', 'brand'])
importer = HierarchyImporter(relations_only=True)
importer.import_from_dataframe(level=product, df=product_df)
# Результат: description НЕ изменился, brand обновился
duplicate_strategy (по умолчанию: 'last')¶
Определяет, какую запись оставлять при обнаружении дубликатов по ключевому полю (id или shortname).
duplicate_strategy='last' (дефолт):
- Оставляет последнюю запись из дубликатов (с максимальным row_number)
- Полезно, если новейшие данные важнее старых
duplicate_strategy='first':
- Оставляет первую запись из дубликатов (с минимальным row_number)
- Полезно, если первоначальные данные приоритетнее
Пример:
# DataFrame с дубликатами
df = pd.DataFrame([
['prod1', 'First description'], # row_number = 1
['prod1', 'Second description'], # row_number = 2
['prod1', 'Third description'], # row_number = 3
], columns=['shortname', 'description'])
# Оставляем последнюю (дефолт)
importer = HierarchyImporter(duplicate_strategy='last')
importer.import_from_dataframe(level=product, df=df)
# Результат: description = 'Third description'
# Оставляем первую
importer = HierarchyImporter(duplicate_strategy='first')
importer.import_from_dataframe(level=product, df=df)
# Результат: description = 'First description'
Пример быстрого массового переназначения¶
# Самый быстрый режим: ID везде + только связи
# Полезно для массового переназначения родителей из ETL-процессов
new_brand_id = 99999
product_ids_df = pd.DataFrame([
[12345, new_brand_id], # id продукта, id бренда
[12346, new_brand_id],
[12347, new_brand_id],
# ... тысячи записей
], columns=['id', 'brand_id'])
importer = HierarchyImporter(relations_only=True)
importer.import_from_dataframe(level=product, df=product_ids_df)
# Обновляет только dim_brand, используя ID напрямую без нормализации
Использование ID вместо shortname¶
Можно идентифицировать элементы не только по shortname, но и по id. Это позволяет ещё больше ускорить массовые операции.
Преимущества использования ID:
- Быстрее: не требуется поиск по индексу shortname
- Точнее: id не может измениться, shortname может
- Удобнее для ETL: если данные уже содержат id из dim-таблиц
Пример с ID элемента и shortname родителя:
# Получаем ID продуктов из dim_product_direct
# Обновляем связи используя ID продуктов и shortname брендов
df = pd.DataFrame([
[12345, 'brand_b'], # id продукта, shortname бренда
[12346, 'brand_b'],
[12347, 'brand_b'],
], columns=['id', 'brand']) # brand - shortname, будет нормализован автоматически
importer = HierarchyImporter(relations_only=True)
importer.import_from_dataframe(level=product, df=df)
Самый быстрый режим - ID везде:
# Абсолютно самый быстрый режим: ID для всех (элемента И родителей)
brand_b_id = 99999
df = pd.DataFrame([
[12345, brand_b_id], # id продукта, id бренда
[12346, brand_b_id],
[12347, brand_b_id],
], columns=['id', 'brand_id']) # brand_id - ID родителя, используется напрямую
importer = HierarchyImporter(relations_only=True)
importer.import_from_dataframe(level=product, df=df)
# Обновление выполняется одним простым UPDATE без JOIN'ов
Этапы импорта¶
1️⃣ Создание временной таблицы¶
Данные из DataFrame загружаются во временную таблицу fact__{random_name}.
Преимущества: - Атомарность операции (работа в рамках транзакции) - Изоляция от основных данных - Автоматическая очистка после завершения
2️⃣ Валидация и очистка¶
Проверка обязательных полей:
- Наличие колонки shortname или id
- В режиме полного импорта (relations_only=False) колонка shortname обязательна
- В режиме relations_only=True можно использовать либо shortname, либо id для идентификации элементов
- Выброс ошибки если отсутствуют обе колонки (shortname и id)
Удаление дубликатов:
- Дубликаты удаляются по ключевому полю (id или shortname)
- Если передан id, дедупликация происходит по id
- Если передан только shortname, дедупликация происходит по shortname
- Остаётся последняя запись для каждого уникального ключа
- Логируется количество удалённых дубликатов
Очистка NULL значений:
- Удаляются строки с NULL значениями в ключевом поле
- Для shortname: shortname IS NULL OR shortname = ''
- Для id: id IS NULL
3️⃣ Нормализация родителей¶
Для каждого родительского уровня выполняется преобразование shortname → ID.
Процесс:
# Получаем список родительских уровней
parent_levels = list(level.get_children())
for parent in parent_levels:
parent_key = parent.key # например, 'brand'
parent_id_field = f"dim_{parent_key}" # 'dim_brand'
parent_dim_table = f"dim_{parent_key}_direct"
# Добавляем колонку для ID родителя
ALTER TABLE {temp_table} ADD COLUMN {parent_id_field} BIGINT
# Заполняем через JOIN
UPDATE {temp_table} tmp
SET {parent_id_field} = parent.id
FROM {parent_dim_table} parent
WHERE tmp.{parent_key} = parent.shortname
Обработка отсутствующих родителей:
При создании импортера можно указать политику:
importer = HierarchyImporter(on_missing_parent='raise_error') # Выбросить ошибку
importer = HierarchyImporter(on_missing_parent='set_null') # Установить NULL
4️⃣ UPSERT в dim_*_direct¶
Выполняется массовая вставка/обновление данных с использованием PostgreSQL INSERT ... ON CONFLICT.
Логика установки флагов:
INSERT INTO dim_product_direct (
shortname, description, is_active, is_new,
needs_sync, update_item, update_parents,
dim_brand, dim_category
)
SELECT
shortname,
COALESCE(description, '') as description,
TRUE as is_active,
TRUE as is_new,
TRUE as needs_sync, -- Для синхронизации dim_*_full
TRUE as update_item, -- ⚠️ Для синхронизации с Item
TRUE as update_parents, -- ⚠️ Для синхронизации родительских связей
dim_brand::bigint,
dim_category::bigint
FROM {temp_table}
ON CONFLICT (shortname) DO UPDATE SET
description = EXCLUDED.description,
is_new = EXCLUDED.is_new,
update_item = TRUE,
update_parents = CASE
WHEN dim_product_direct.dim_brand IS DISTINCT FROM EXCLUDED.dim_brand
OR dim_product_direct.dim_category IS DISTINCT FROM EXCLUDED.dim_category
THEN TRUE
ELSE dim_product_direct.update_parents
END,
needs_sync = dim_product_direct.needs_sync,
dim_brand = EXCLUDED.dim_brand,
dim_category = EXCLUDED.dim_category
Ключевые моменты:
- update_item = TRUE — всегда устанавливается при UPSERT
- update_parents = TRUE — только если изменились родительские поля
- Для календарных уровней также проверяются start_date, end_date, num
5️⃣ Автоматическая синхронизация¶
После успешного UPSERT автоматически запускаются следующие синхронизации (если включены):
5.1 Синхронизация с Item (⚠️ временная)¶
Управление: параметр sync_item_bridge (по умолчанию True)
Если sync_item_bridge=True и были добавлены/обновлены элементы (added_count + updated_count > 0), запускается асинхронная синхронизация через Celery:
from planiqum.core.hierarchy.tasks import sync_item_from_dim_direct_task
# Запуск через Celery (асинхронно)
task = sync_item_from_dim_direct_task.apply_async(args=[level.id])
Что синхронизируется:
- Item — создание/обновление элементов иерархии
- core_hierarchy_item_parents — обновление родительских связей
- HorizonItem — для календарных уровней
Отключение:
# Отключить синхронизацию ItemBridge
importer = HierarchyImporter(sync_item_bridge=False)
importer.import_from_dataframe(level=product, df=product_df)
⚠️ Временный механизм — будет удалён после полного отказа от Item.
5.2 Синхронизация dim_*_full¶
Управление: параметр auto_sync (по умолчанию True)
Если auto_sync=True и были добавлены/обновлены элементы, автоматически вызывается:
from planiqum.core.hierarchy.libs.direct.dim_table_full import DimTableFull
syncer = DimTableFull()
# Синхронизация структуры (создание/обновление колонок)
syncer.sync_structure(level)
# Синхронизация содержимого (атрибуты + связи)
sync_result = syncer.sync_content(level)
# Возвращает: {'inserted': int, 'updated': int, 'attrs_updated': int}
Что синхронизируется:
- Структура таблицы dim_{level}_full (колонки для всех предков)
- Атрибуты элементов (description, start_date, end_date, num)
- Иерархические связи (все предки для каждого элемента)
Отключение:
# Отключить автосинхронизацию dim_*_full
importer = HierarchyImporter(auto_sync=False)
importer.import_from_dataframe(level=product, df=product_df)
# Синхронизацию можно выполнить вручную позже
from planiqum.core.hierarchy.libs.direct.dim_table_full import DimTableFull
DimTableFull().sync_content(level)
Комбинирование параметров:
# Только dim_*_full, без ItemBridge
importer = HierarchyImporter(auto_sync=True, sync_item_bridge=False)
# Только ItemBridge, без dim_*_full
importer = HierarchyImporter(auto_sync=False, sync_item_bridge=True)
# Без синхронизаций (только импорт в dim_*_direct)
importer = HierarchyImporter(auto_sync=False, sync_item_bridge=False)
Импорт календарных данных¶
Для календарных уровней (level.is_calendar = True) необходимо передавать дополнительные поля:
week_df = pd.DataFrame([
['2024-W01', 'Week 1', '2024-01-01', '2024-01-07', 1],
['2024-W02', 'Week 2', '2024-01-08', '2024-01-14', 2],
], columns=['shortname', 'description', 'start_date', 'end_date', 'num'])
importer.import_from_dataframe(level=week_level, df=week_df)
Автоматическое приведение типов:
- start_date, end_date → приводятся к DATE
- num → приводится к INTEGER
Установка флага update_horizon:
update_horizon = CASE
WHEN COALESCE(dim_week_direct.start_date::text, '') != COALESCE(EXCLUDED.start_date::text, '')
OR COALESCE(dim_week_direct.end_date::text, '') != COALESCE(EXCLUDED.end_date::text, '')
OR COALESCE(dim_week_direct.num::text, '') != COALESCE(EXCLUDED.num::text, '')
THEN TRUE
ELSE dim_week_direct.update_horizon
END
Нормализация родителей¶
Получение родительских уровней¶
В проекте используется обратная логика MPTT:
# Если brand.child = product, то:
product.get_children() # вернёт [brand]
brand.get_children() # вернёт []
# Для получения родителей уровня используем get_children():
parent_levels = list(level.get_children())
Преобразование shortname в ID¶
Для каждого родителя создаётся колонка dim_{parent_key} и заполняется через JOIN:
-- Для parent_level.key = 'brand'
UPDATE {temp_table} tmp
SET dim_brand = parent.id
FROM dim_brand_direct parent
WHERE tmp.brand = parent.shortname
Обработка множественных родителей¶
Пример структуры:
product
|-- brand
|-- category
В DataFrame должны быть колонки brand и category:
product_df = pd.DataFrame([
['prod1', 'Product 1', 'brand_a', 'cat_x']
], columns=['shortname', 'description', 'brand', 'category'])
После нормализации в dim_product_direct будут заполнены:
- dim_brand = ID элемента 'brand_a'
- dim_category = ID элемента 'cat_x'
Обработка ошибок¶
Отсутствующие родители¶
Режим raise_error:
importer = HierarchyImporter(on_missing_parent='raise_error')
importer.import_from_dataframe(level=product, df=product_df)
# ValueError: Родители уровня 'brand' не найдены:
# shortname brand
# prod1 brand_x (не существует)
Режим set_null:
importer = HierarchyImporter(on_missing_parent='set_null')
importer.import_from_dataframe(level=product, df=product_df)
# WARNING: Не найдено 1 родителей 'brand', устанавливаем NULL
# dim_brand будет NULL для элементов с несуществующими родителями
Дубликаты shortname¶
Дубликаты автоматически удаляются (остаётся последняя запись):
# DataFrame с дубликатами
df = pd.DataFrame([
['prod1', 'First'],
['prod1', 'Second'], # Дубликат - останется эта запись
], columns=['shortname', 'description'])
importer.import_from_dataframe(level=product, df=df)
# INFO: Удалено дубликатов: 1
Производительность¶
Массовые операции¶
Весь импорт выполняется через массовые SQL-операции: - Один UPSERT для всех элементов - Автоматическая установка флагов для синхронизации - Нет циклов на уровне Python
Транзакционность¶
Весь процесс импорта выполняется в одной транзакции: - При ошибке откатываются все изменения - Гарантируется консистентность данных
Оптимизация для больших объёмов¶
Для импорта миллионов записей используйте батчи:
chunk_size = 100000
for chunk in pd.read_csv('data.csv', chunksize=chunk_size):
importer.import_from_dataframe(level=product, df=chunk)
Журнал импорта¶
С версии 2.0 все импорты логируются в специальные таблицы в схеме import_logs.
Структура журнала¶
Для каждого уровня иерархии создаётся партиционированная таблица:
- Главная таблица: import_logs.hierarchy_import_{level_id}
- Партиция для сессии: import_logs.hierarchy_import_{level_id}_{session_id}
Колонки журнала:
- row_number — номер строки из исходных данных
- session_id — ID сессии импорта
- id, shortname, description — исходные данные
- dim_{parent}_raw — исходное значение родителя (shortname или id::TEXT)
- dim_{parent} — нормализованный ID родителя
- error — запись не импортирована (критическая ошибка)
- warning — запись импортирована с предупреждением
- is_new — новая запись (не существовала в dim_*_direct)
- needs_attr_sync — требуется синхронизация атрибутов в dim_*_full
- needs_rel_sync — требуется синхронизация связей в dim_*_full
Флаги состояния¶
error = TRUE:
- Основной уровень не нормализован при relations_only=True
- Запись не импортируется в dim_*_direct
warning = TRUE:
- Один или более родителей не нормализованы
- Запись импортируется с dim_{parent} = NULL для проблемных родителей
is_new = TRUE:
- Запись отсутствует в dim_*_direct (определяется по shortname/id)
- Будет создана новая запись
needs_attr_sync = TRUE:
- Изменились атрибуты: description, start_date, end_date, num
- Требуется обновление этих полей в dim_*_full
needs_rel_sync = TRUE:
- Изменились родительские связи (любое dim_{parent})
- Требуется пересчёт всех предков в dim_*_full
Доступ к логам импорта¶
from planiqum.core.import_audit.models import HierarchyImportSession
# Получение последней сессии импорта
session = HierarchyImportSession.objects.filter(
level=product,
status=HierarchyImportSession.STATUS_SUCCESS
).order_by('-created_at').first()
# Запрос к журналу импорта
log_table = f"import_logs.hierarchy_import_{product.id}_{session.id}"
df = select_to_df(f"""
SELECT
row_number, shortname, description,
error, warning, is_new
FROM {log_table}
WHERE error = FALSE -- Только успешные записи
ORDER BY row_number
""")
Логирование пользователя и комментария¶
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.get(username='admin')
importer = HierarchyImporter(
user=user,
comment="Импорт из SAP за 2024-01"
)
importer.import_from_dataframe(level=product, df=product_df)
# Проверка сессии
session = HierarchyImportSession.objects.latest('created_at')
print(session.created_by) # admin
print(session.comment) # "Импорт из SAP за 2024-01"
Техническая реализация¶
Использование PyPika¶
Все SQL-запросы строятся с помощью библиотеки PyPika для: - Безопасности (защита от SQL-injection) - Читаемости кода - Тестируемости
Hybrid-подход для PostgreSQL-специфичных конструкций:
- Основной SELECT/JOIN/CASE → PyPika
- IS DISTINCT FROM → raw SQL (не поддерживается PyPika)
- DISTINCT ON → raw SQL обёртка (PostgreSQL-специфично)
Пример из кода:
from pypika import Query, Table, Case
norm = Table('normalized_table', alias='norm')
existing = Table('dim_product_direct', alias='existing')
# Построение SELECT с CASE через PyPika
query = Query.from_(norm).select(
norm.shortname,
Case().when(existing.id.isnull(), True).else_(False).as_('is_new')
).left_join(existing).on(norm.shortname == existing.shortname)
# Получение SQL
sql = query.get_sql()
Архитектура нормализации¶
Этапы:
temp_table— исходные данные из DataFrame/файлаnormalized_table— промежуточная таблица с:row_number(IDENTITY) — автоинкремент для трассировки- Все исходные колонки
- Нормализованные
dim_{parent}колонки (результаты LEFT JOIN) import_log— финальная таблица с флагами и статистикойdim_*_direct— целевая таблица (UPSERT из лога)
Управление временными таблицами¶
Используется класс TempTable с автоматической очисткой через weakref.finalize:
from planiqum.core.libs.temp_table import TempTable
# Создание временной таблицы с GC-управлением
temp_table = TempTable.create("""
CREATE TEMP TABLE {table_name} (
id BIGINT,
shortname TEXT
)
""")
# Таблица автоматически удалится при выходе из области видимости
# Или явное удаление:
temp_table.drop()
См. также¶
- Архитектура dim_*_direct
- Синхронизация Item (временная)
- Direct Pipeline: Руководство для разработчиков
Важно: описанные настройки и сценарии могут отличаться в вашей инсталляции Planiqum
За уточнениями и методологической поддержкой обращайтесь в компанию
ЮНИК СОФТ