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