Перейти к содержанию

Direct Pipeline: Импорт и миграция данных

Команда миграции данных

Для миграции данных со старого механизма (Item) на новый (Direct Pipeline) используется команда:

python manage.py migrate_to_direct_pipeline

Параметры команды

  • --type=<type_key> — мигрировать только уровни указанного типа (например, --type=products)
  • --level=<level_key> — мигрировать только указанный уровень (например, --level=product)
  • --dry-run — показать что будет сделано без выполнения

Что делает команда

  1. Синхронизация структуры:
  2. Создаёт/обновляет структуру dim_*_direct таблиц (колонки, индексы)
  3. Создаёт/обновляет структуру dim_*_full таблиц (колонки, индексы)

  4. Миграция контента:

  5. Синхронизирует данные из Item в dim_*_direct
  6. Автоматически синхронизирует 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

Важные замечания

  1. Идемпотентность: Команда безопасна для повторного запуска
  2. Использует ON CONFLICT для обновления существующих записей
  3. Проверяет существование колонок перед добавлением

  4. Порядок синхронизации: Уровни обрабатываются в правильном порядке (от родителей к детям)

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

  6. Рекомендуется запускать в фоне или через 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 через Celery
  • False: не запускать синхронизацию 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️⃣ Нормализация родителей

Для каждого родительского уровня выполняется преобразование shortnameID.

Процесс:

# Получаем список родительских уровней
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()

Архитектура нормализации

Этапы:

  1. temp_table — исходные данные из DataFrame/файла
  2. normalized_table — промежуточная таблица с:
  3. row_number (IDENTITY) — автоинкремент для трассировки
  4. Все исходные колонки
  5. Нормализованные dim_{parent} колонки (результаты LEFT JOIN)
  6. import_log — финальная таблица с флагами и статистикой
  7. 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()

См. также


Важно: описанные настройки и сценарии могут отличаться в вашей инсталляции Planiqum
За уточнениями и методологической поддержкой обращайтесь в компанию ЮНИК СОФТ