Как построить обратную цепочку документов по производственным переделам в 1С?

Программист 1С v8.3 (Управляемые формы) Управленческий учет Промышленность, строительство и АПК
← На главную

В производственном учете на платформе 1С одной из наиболее сложных и востребованных задач является обеспечение полной прослеживаемости партий. Нам часто требуется ответить на вопрос: из какого именно сырья была произведена конкретная партия готовой продукции? Если производство многопередельное (когда сырье превращается в полуфабрикат первого уровня, затем второго, третьего и так далее до готовой продукции), задача усложняется многократно. Ситуация становится еще более интересной, если на каждом переделе происходит слияние или разделение партий.

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


Шаг 1. Проектирование архитектуры хранения связей (Регистр сведений)

Попытка строить цепочки переделов «на лету» путем анализа движений регистров накопления («Затраты», «Выпуск», «Производство») в реальном времени — это классическая архитектурная ошибка. Такие запросы будут содержать огромное количество соединений, выполняться крайне медленно и создавать колоссальную нагрузку на СУБД.

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

Давайте определим оптимальную структуру нашего регистра сведений:

Рассмотрим пример того, как может выглядеть код записи в этот регистр в модуле проведения производственного документа (например, ОтчетПроизводстваЗаСмену или аналогичного документа передела):


Процедура ЗаписатьСвязиПеределов(Отказ, РежимПроведения)
    
    // Создаем набор записей по нашему документу
    НаборЗаписей = РегистрыСведений.СвязиПеределовПартий.СоздатьНаборЗаписей();
    НаборЗаписей.Отбор.Регистратор.Установить(ЭтотОбъект.Ссылка);
    
    // В реальном документе данные берутся из табличных частей списания и выпуска.
    // Предположим, у нас в документе есть одна ВыходнаяПартия и табличная часть "СписанныеМатериалы"
    Для Каждого СтрокаМатериалов Из ЭтотОбъект.СписанныеМатериалы Цикл
        
        НоваяЗапись = НаборЗаписей.Добавить();
        НоваяЗапись.Период          = ЭтотОбъект.Дата;
        НоваяЗапись.ВыходнаяПартия  = ЭтотОбъект.ВыходнаяПартия;
        НоваяЗапись.ВходнаяПартия   = СтрокаМатериалов.ВходнаяПартия;
        НоваяЗапись.ДокументПередела = ЭтотОбъект.Ссылка;
        НоваяЗапись.НоменклатураВход = СтрокаМатериалов.Номенклатура;
        НоваяЗапись.НоменклатураВыход = ЭтотОбъект.ВыходнаяНоменклатура;
        НоваяЗапись.Количество      = СтрокаМатериалов.Количество;
        
    КонецЦикла;
    
    НаборЗаписей.Записать();
    
КонецПроцедуры

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


Шаг 2. Реализация алгоритма построения цепочки

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

Подход А. Итерационный запрос с использованием временных таблиц (Оптимальный для СУБД)

Этот подход идеален, если вы строите дерево для крупных партий с большим количеством уровней переделов. Он позволяет избежать проблемы «N+1 запрос» (вызова запросов в цикле встроенного языка), выполняя последовательные шаги внутри СУБД с использованием менеджера временных таблиц.

Рассмотрим детально этот алгоритм:


Функция ПостроитьЦепочкуИтерационнымЗапросом(СтартоваяПартия) Экспорт
    
    МВТ = Новый МенеджерВременныхТаблиц;
    Запрос = Новый Запрос;
    Запрос.МенеджерВременныхТаблиц = МВТ;
    
    // Шаг 1. Инициализируем временную таблицу "ВТ_ТекущиеВыходы" стартовой партией готовой продукции
    Запрос.Текст = 
    "ВЫБРАТЬ
    |	&СтартоваяПартия КАК ВыходнаяПартия
    |ПОМЕСТИТЬ ВТ_ТекущиеВыходы";
    
    Запрос.УстановитьПараметр("СтартоваяПартия", СтартоваяПартия);
    Запрос.Выполнить();
    
    // Шаг 2. Создаем пустую накопительную таблицу для итогового результата
    ЗапросИнициализации = Новый Запрос;
    ЗапросИнициализации.МенеджерВременныхТаблиц = МВТ;
    ЗапросИнициализации.Текст = 
    "ВЫБРАТЬ ПЕРВЫЕ 0
    |	Связи.ВыходнаяПартия КАК ВыходнаяПартия,
    |	Связи.ВходнаяПартия КАК ВходнаяПартия,
    |	Связи.ДокументПередела КАК ДокументПередела,
    |	Связи.НоменклатураВход КАК НоменклатураВход,
    |	Связи.НоменклатураВыход КАК НоменклатураВыход,
    |	Связи.Количество КАК Количество,
    |	1 КАК Уровень
    |ПОМЕСТИТЬ ВТ_ИтоговаяЦепочка
    |ИЗ
    |	РегистрСведений.СвязиПеределовПартий КАК Связи";
    ЗапросИнициализации.Выполнить();
    
    // Максимальное количество переделов (ограничение для предотвращения вечных циклов)
    ПредельноеКоличествоПеределов = 15;
    
    Для Шаг = 1 По ПредельноеКоличествоПеределов Цикл
        
        ЗапросШага = Новый Запрос;
        ЗапросШага.МенеджерВременныхТаблиц = МВТ;
        
        // Находим все входящие партии для наших текущих выходных партий
        ЗапросШага.Текст = 
        "ВЫБРАТЬ
        |	Связи.ВыходнаяПартия КАК ВыходнаяПартия,
        |	Связи.ВходнаяПартия КАК ВходнаяПартия,
        |	Связи.ДокументПередела КАК ДокументПередела,
        |	Связи.НоменклатураВход КАК НоменклатураВход,
        |	Связи.НоменклатураВыход КАК НоменклатураВыход,
        |	Связи.Количество КАК Количество,
        |	&ТекущийШаг КАК Уровень
        |ПОМЕСТИТЬ ВТ_НайденныеСвязи
        |ИЗ
        |	ВТ_ТекущиеВыходы КАК Текущие
        |	ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.СвязиПеределовПартий КАК Связи
        |		ПО Текущие.ВыходнаяПартия = Связи.ВыходнаяПартия;
        |
        |////////////////////////////////////////////////////////////////////////////////
        |ВЫБРАТЬ
        |	ВТ_НайденныеСвязи.ВходнаяПартия КАК ВыходнаяПартия
        |ПОМЕСТИТЬ ВТ_НовыеВыходы
        |ИЗ
        |	ВТ_НайденныеСвязи КАК ВТ_НайденныеСвязи;
        |
        |////////////////////////////////////////////////////////////////////////////////
        |ВСТАВИТЬ В ВТ_ИтоговаяЦепочка
        |ВЫБРАТЬ
        |	ВТ_НайденныеСвязи.ВыходнаяПартия,
        |	ВТ_НайденныеСвязи.ВходнаяПартия,
        |	ВТ_НайденныеСвязи.ДокументПередела,
        |	ВТ_НайденныеСвязи.НоменклатураВход,
        |	ВТ_НайденныеСвязи.НоменклатураВыход,
        |	ВТ_НайденныеСвязи.Количество,
        |	ВТ_НайденныеСвязи.Уровень
        |ИЗ
        |	ВТ_НайденныеСвязи КАК ВТ_НайденныеСвязи";
        
        ЗапросШага.УстановитьПараметр("ТекущийШаг", Шаг);
        РезультатыШага = ЗапросШага.ВыполнитьПакет();
        
        // Проверяем, нашли ли мы новые связи на этом шаге
        ТаблицаНовыхВыходов = РезультатыШага[1].Выгрузить();
        Если ТаблицаНовыхВыходов.Количество() = 0 Тогда
            Прервать; // Цепочка оборвалась (достигли первичного сырья)
        КонецЕсли;
        
        // Обновляем временную таблицу текущих выходов для следующей итерации
        ЗапросОбновления = Новый Запрос;
        ЗапросОбновления.МенеджерВременныхТаблиц = МВТ;
        ЗапросОбновления.Текст = 
        "УНИЧТОЖИТЬ ВТ_ТекущиеВыходы;
        |
        |ВЫБРАТЬ
        |	Новые.ВыходнаяПартия КАК ВыходнаяПартия
        |ПОМЕСТИТЬ ВТ_ТекущиеВыходы
        |ИЗ
        |	ВТ_НовыеВыходы КАК Новые;
        |
        |УНИЧТОЖИТЬ ВТ_НовыеВыходы;
        |УНИЧТОЖИТЬ ВТ_НайденныеСвязи;";
        ЗапросОбновления.Выполнить();
        
    КонецЦикла;
    
    // Получаем итоговый результат из временной таблицы СУБД
    ЗапросИтога = Новый Запрос;
    ЗапросИтога.МенеджерВременныхТаблиц = МВТ;
    ЗапросИтога.Текст = 
    "ВЫБРАТЬ
    |	Итог.ВыходнаяПартия КАК ВыходнаяПартия,
    |	Итог.ВходнаяПартия КАК ВходнаяПартия,
    |	Итог.ДокументПередела КАК ДокументПередела,
    |	Итог.НоменклатураВход КАК НоменклатураВход,
    |	Итог.НоменклатураВыход КАК НоменклатураВыход,
    |	Итог.Количество КАК Количество,
    |	Итог.Уровень КАК Уровень
    |ИЗ
    |	ВТ_ИтоговаяЦепочка КАК Итог
    |УПОРЯДОЧИТЬ ПО
    |	Итог.Уровень Возв";
    
    Возврат ЗапросИтога.Выполнить().Выгрузить();
    
КонецФункции

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

Подход Б. Кэширование связей в ОЗУ + рекурсивный обход

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

Проанализируем этот программный код:


Функция ПостроитьЦепочкуВПамяти(СтартоваяПартия, ДатаНачалаАнализа) Экспорт
    
    // Шаг 1. Выгружаем все связи за интересующий нас период в таблицу значений
    Запрос = Новый Запрос;
    Запрос.Текст = 
    "ВЫБРАТЬ
    |	Связи.ВыходнаяПартия КАК ВыходнаяПартия,
    |	Связи.ВходнаяПартия КАК ВходнаяПартия,
    |	Связи.ДокументПередела КАК ДокументПередела,
    |	Связи.НоменклатураВход КАК НоменклатураВход,
    |	Связи.НоменклатураВыход КАК НоменклатураВыход,
    |	Связи.Количество КАК Количество
    |ИЗ
    |	РегистрСведений.СвязиПеределовПартий КАК Связи
    |ГДЕ
    |	Связи.Период >= &ДатаНачалаАнализа";
    
    Запрос.УстановитьПараметр("ДатаНачалаАнализа", ДатаНачалаАнализа);
    ТаблицаСвязей = Запрос.Выполнить().Выгрузить();
    
    // Для оптимизации поиска обязательно строим индекс по выходной партии
    ТаблицаСвязей.Индексы.Добавить("ВыходнаяПартия");
    
    // Создаем структуру Дерева Значений для результирующего вывода
    ДеревоРезультат = Новый ДеревоЗначений;
    ДеревоРезультат.Колонки.Добавить("ВыходнаяПартия");
    ДеревоРезультат.Колонки.Добавить("ВходнаяПартия");
    ДеревоРезультат.Колонки.Добавить("ДокументПередела");
    ДеревоРезультат.Колонки.Добавить("НоменклатураВход");
    ДеревоРезультат.Колонки.Добавить("НоменклатураВыход");
    ДеревоРезультат.Колонки.Добавить("Количество");
    
    // Используем соответствие для отслеживания уже посещенных узлов (профилактика зацикливаний)
    ПосещенныеПартии = Новый Соответствие;
    
    // Запускаем рекурсию
    ОбходВладельцевРекурсивно(СтартоваяПартия, ДеревоРезультат.Строки, ТаблицаСвязей, ПосещенныеПартии);
    
    Возврат ДеревоРезультат;
    
КонецФункции

Процедура ОбходВладельцевРекурсивно(ТекущаяПартия, КоллекцияСтрокДерева, ТаблицаСвязей, ПосещенныеПартии)
    
    // Проверяем, не заходили ли мы уже в эту ветку (защита от зацикливания)
    Если ПосещенныеПартии[ТекущаяПартия] <> Неопределено Тогда
        НоваяСтрока = КоллекцияСтрокДерева.Добавить();
        НоваяСтрока.ВыходнаяПартия = ТекущаяПартия;
        НоваяСтрока.НоменклатураВход = "<Обнаружен циклический возврат партии!>";
        Возврат;
    КонецЕсли;
    
    // Фиксируем посещение текущей партии в данной ветке дерева
    ПосещенныеПартии.Вставить(ТекущаяПартия, Истина);
    
    // Ищем записи по выходной партии (быстрый поиск по индексированной ТЗ)
    Отбор = Новый Структура("ВыходнаяПартия", ТекущаяПартия);
    НайденныеСтроки = ТаблицаСвязей.НайтиСтроки(Отбор);
    
    Для Каждого СтрокаСвязи Из НайденныеСтроки Цикл
        
        НоваяСтрока = КоллекцияСтрокДерева.Добавить();
        ЗаполнитьЗначенияСвойств(НоваяСтрока, СтрокаСвязи);
        
        // Рекурсивный спуск на следующий уровень передела
        ОбходВладельцевРекурсивно(СтрокаСвязи.ВходнаяПартия, НоваяСтрока.Строки, ТаблицаСвязей, ПосещенныеПартии);
        
    КонецЦикла;
    
    // Очищаем отметку посещения при выходе из ветки (чтобы партия могла анализироваться в других ветках)
    ПосещенныеПартии.Исключить(ТекущаяПартия);
    
КонецПроцедуры

Шаг 3. Обработка циклических зависимостей (Зацикливания)

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

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

Если не предусмотреть защиту от циклов вида Партия А -> Партия Б -> Партия В -> Партия А, наш алгоритм уйдет в бесконечную рекурсию, что приведет к переполнению стека (Stack Overflow) или зависанию сеанса пользователя.

В приведенном выше коде Подхода Б мы успешно решили эту проблему с помощью коллекции ПосещенныеПартии (тип Соответствие). Давайте разберем этот принцип подробнее:


Шаг 4. Отображение результатов в пользовательском интерфейсе

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

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


&НаСервере
Процедура ПостроитьДеревоНаФорме(СтартоваяПартия)
    
    // Получаем плоскую таблицу результатов
    ТаблицаСвязей = ПостроитьЦепочкуИтерационнымЗапросом(СтартоваяПартия);
    
    // Очищаем дерево на форме
    ДеревоФормы = РеквизитФормыВЗначение("ДеревоПеределов");
    ДеревоФормы.Строки.Очистить();
    
    // Создаем корневой элемент
    КорневойУзел = ДеревоФормы.Строки.Добавить();
    КорневойУзел.ВыходнаяПартия = СтартоваяПартия;
    КорневойУзел.НоменклатураВыход = СтартоваяПартия.Владелец; // Предположим, владелец партии - Номенклатура
    
    // Заполняем подчиненные уровни
    ЗаполнитьУровеньДерева(КорневойУзел, ТаблицаСвязей);
    
    ЗначениеВРеквизитФормы(ДеревоФормы, "ДеревоПеределов");
    
КонецПроцедуры

&НаСервере
Процедура ЗаполнитьУровеньДерева(РодительскийУзел, ТаблицаСвязей)
    
    // Находим в плоской таблице все строки, где выходом является текущая партия родителя
    Отбор = Новый Структура("ВыходнаяПартия", РодительскийУзел.ВыходнаяПартия);
    СтрокиСвязей = ТаблицаСвязей.НайтиСтроки(Отбор);
    
    Для Каждого СтрокаСвязи Из СтрокиСвязей Цикл
        
        ПодчиненныйУзел = РодительскийУзел.Строки.Добавить();
        ЗаполнитьЗначенияСвойств(ПодчиненныйУзел, СтрокаСвязи);
        
        // Рекурсивно строим ветки дальше для найденной входной партии
        ЗаполнитьУровеньДерева(ПодчиненныйУзел, ТаблицаСвязей);
        
    КонецЦикла;
    
КонецПроцедуры

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

← На главную