Как правильно использовать левое соединение в запросах 1С и объединить смежные периоды дат?

Программист 1С v8.3 (Управляемые формы) IT и автоматизация бизнеса
← На главную

При работе с запросами в платформе 1С:Предприятие разработчики регулярно сталкиваются с двумя классическими проблемами. Первая — это неожиданное поведение левого соединения, когда оно начинает работать как внутреннее, отсекая нужные записи. Вторая — более сложная алгоритмическая задача, известная в базах данных как Gaps and Islands (Интервалы и периоды), когда требуется «схлопнуть» несколько строк со смежными или пересекающимися периодами дат в один общий интервал.

В этой статье мы подробно разберем обе проблемы. Мы выясним причину, почему левое соединение теряет записи, научимся правильно накладывать условия на левую и правую таблицы, а также разберем два практических метода объединения периодов: с помощью обработки таблицы значений в коде 1С и с помощью чистого, оптимизированного запроса к СУБД.

---

Часть 1. Почему ЛЕВОЕ СОЕДИНЕНИЕ превращается во внутреннее и как это исправить?

Рассмотрим ситуацию, с которой столкнулся автор темы. Изначально у нас есть запрос, в котором мы хотим выбрать все товары из левой таблицы (документа) и сопоставить их с данными правой таблицы по определенному виду товара. Если соответствия в правой таблице нет, мы все равно хотим видеть товар из левой таблицы (с пустыми значениями справа).

Автор написал следующий запрос:


ВЫБРАТЬ
    ВложенныйЗапрос.Наименование КАК Наименование,
    Товары.Наименование КАК Наименование1,
    Товары.ВидТовара,
    Товары.СрокХраненияС,
    Товары.СрокХраненияДо
ИЗ
    (ВЫБРАТЬ
        НакладныеНаименование.Наименование КАК Наименование
    ИЗ
        Документ.Накладные.Наименование КАК НакладныеНаименование
    ГДЕ
        НакладныеНаименование.Ссылка = &Ссылка
    СГРУППИРОВАТЬ ПО
        НакладныеНаименование.Наименование) КАК ВложенныйЗапрос
    ЛЕВОЕ СОЕДИНЕНИЕ Документ.Накладные.Наименование КАК Товары
    ПО ВложенныйЗапрос.Наименование = Товары.Наименование
ГДЕ
    Товары.Ссылка = &Ссылка
    И Товары.ВидТовара = &ВидТовара

При выполнении этого запроса товары, у которых ВидТовара не соответствует параметру &ВидТовара, просто исчезают из результата. Левое соединение отработало как внутреннее (INNER JOIN). Проанализируем ситуацию и выясним причину.

Физика процесса в СУБД:

Когда СУБД выполняет этот запрос, она действует по строгому алгоритму:

  1. Сначала происходит левое соединение таблиц по условию в секции ПО. На этом этапе в промежуточном результате присутствуют абсолютно все строки из левой таблицы. Для тех строк, которым не нашлось соответствия в правой таблице Товары, все поля правой таблицы заполняются значениями NULL.
  2. Затем СУБД переходит к фильтрации полученного набора данных по условиям из секции ГДЕ.
  3. В секции ГДЕ написано: Товары.ВидТовара = &ВидТовара. Если для какого-то товара из левой таблицы не было найдено соответствия справа, поле Товары.ВидТовара приняло значение NULL. Начинает вычисляться логическое выражение: NULL = &ВидТовара. В трехзначной логике SQL сравнение любого значения с NULL возвращает результат UNKNOWN (ложь).
  4. В итоге все строки, для которых правая таблица не вернула совпадений, безжалостно отсекаются фильтром ГДЕ.

Правило разработки: Если вы используете ЛЕВОЕ СОЕДИНЕНИЕ, любые условия-ограничения на поля правой (присоединяемой) таблицы должны находиться внутри секции ПО (условие связи), а не в секции ГДЕ. Условия на поля левой (основной) таблицы могут оставаться в секции ГДЕ.

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


ВЫБРАТЬ
    ВложенныйЗапрос.Наименование КАК Наименование,
    Товары.Наименование КАК Наименование1,
    Товары.ВидТовара,
    Товары.СрокХраненияС,
    Товары.СрокХраненияДо
ИЗ
    (ВЫБРАТЬ
        НакладныеНаименование.Наименование КАК Наименование
    ИЗ
        Документ.Накладные.Наименование КАК НакладныеНаименование
    ГДЕ
        НакладныеНаименование.Ссылка = &Ссылка
    СГРУППИРОВАТЬ ПО
        НакладныеНаименование.Наименование) КАК ВложенныйЗапрос
    
    ЛЕВОЕ СОЕДИНЕНИЕ Документ.Накладные.Наименование КАК Товары
    ПО ВложенныйЗапрос.Наименование = Товары.Наименование
        И Товары.Ссылка = &Ссылка
        И Товары.ВидТовара = &ВидТовара
АВТОУПОРЯДОЧИВАНИЕ

Теперь запрос отработает корректно: СУБД сначала отберет в правой таблице только те записи, которые соответствуют условиям Ссылка и ВидТовара, а затем соединит их с левой таблицей. Все позиции из левой таблицы останутся в результирующей выборке.

---

Часть 2. Объединение смежных и перекрывающихся интервалов дат (Gaps and Islands)

Теперь перейдем ко второй, более сложной задаче, которую автор озвучил в сообщении 14. Дана таблица интервалов хранения (или аренды, годности и т.д.):

Наименование ДатаС ДатаДо Тип
Яблоки 13.07 03.08 основная
Груши 11.07 07.08 основная
Груши 08.08 23.08 дополнительная
Картофель 20.07 28.07 основная
Картофель 29.07 13.08 дополнительная
Картофель 14.08 16.08 фактическая

Задача: Если конец предыдущего периода и начало следующего являются смежными (разница в 1 день, например: 07.08 и 08.08) или пересекаются, их необходимо «схлопнуть» в один длинный период. Если периоды разорваны (между ними есть свободные дни), их нужно оставить отдельными строками.

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

---

Решение Метод 1. Схлопывание периодов в коде (через Таблицу Значений)

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

Рассмотрим по шагам алгоритм на встроенном языке 1С:

  1. Получаем данные из БД без объединения, упорядочивая их по аналитике (Номенклатура) и дате начала периода.
  2. Выгружаем результат в ТаблицаЗначений.
  3. Создаем новую (результирующую) таблицу значений такой же структуры.
  4. Обходим исходную таблицу. Для каждой строки проверяем: совпадает ли номенклатура с предыдущей строкой и попадает ли дата начала текущей строки в диапазон «предыдущая дата окончания + 1 день».
  5. Если условия выполняются, мы увеличиваем дату окончания в результирующей строке. Если нет — добавляем новую строку в результирующую таблицу.

Посмотрим на пример программной реализации этого алгоритма:


// Предположим, РезультатЗапроса содержит исходные данные из таблицы выше
ТаблицаДанных = РезультатЗапроса.Выгрузить();

// Обязательно сортируем таблицу по аналитике и по возрастанию даты начала
ТаблицаДанных.Сортировать("Наименование Возр, ДатаС Возр");

// Создаем таблицу для хранения свернутого результата
ТаблицаРезультат = ТаблицаДанных.СкопироватьКолонки();

ТекущаяСтрокаРезультата = Неопределено;

Для Каждого СтрокаДанных Из ТаблицаДанных Цикл
    
    Если ТекущаяСтрокаРезультата = Неопределено Тогда
        // Самая первая строка таблицы
        ТекущаяСтрокаРезультата = ТаблицаРезультат.Добавить();
        ЗаполнитьЗначенияСвойств(ТекущаяСтрокаРезультата, СтрокаДанных);
        Продолжить;
    КонецЕсли;
    
    // Проверяем условия склейки периодов:
    // 1. Тот же самый товар
    // 2. Текущая ДатаС отстает от предыдущей ДатаДо не более чем на 1 день (смежность)
    //    Или текущая ДатаС меньше предыдущей ДатаДо (пересечение)
    МаксимальноДопустимоеНачало = ТекущаяСтрокаРезультата.ДатаДо + 86400; // +1 день в секундах
    
    Если СтрокаДанных.Наименование = ТекущаяСтрокаРезультата.Наименование 
        И СтрокаДанных.ДатаС <= МаксимальноДопустимоеНачало Тогда
        
        // Схлопываем период: расширяем конечную дату, если она больше текущей сохраненной
        Если СтрокаДанных.ДатаДо > ТекущаяСтрокаРезультата.ДатаДо Тогда
            ТекущаяСтрокаРезультата.ДатаДо = СтрокаДанных.ДатаДо;
        КонецЕсли;
        
    Иначе
        // Обнаружен разрыв периода или сменился товар — создаем новую запись в результате
        ТекущаяСтрокаРезультата = ТаблицаРезультат.Добавить();
        ЗаполнитьЗначенияСвойств(ТекущаяСтрокаРезультата, СтрокаДанных);
    КонецЕсли;
    
КонецЦикла;

// В ТаблицаРезультат теперь лежат схлопнутые периоды

Плюсы метода: Простая отладка, легкая читаемость кода, предсказуемое поведение алгоритма.

Минусы метода: Низкая производительность на больших объемах данных. Если у вас десятки тысяч документов с тысячами строк, сериализация данных из БД, передача их на сервер приложений и последующий обход в цикле могут существенно затормозить систему. Кроме того, этот результат нельзя сразу использовать внутри другого запроса без повторного помещения в менеджер временных таблиц.

---

Решение Метод 2. Чистый запрос 1С (Поиск границ интервалов)

Если мы хотим решить эту задачу максимально производительно на уровне СУБД, нам нужно составить запрос, который сам определит границы непрерывных "островов" дат. Этот метод является классическим решением задачи "Gaps and Islands" в реляционных базах данных.

Проанализируем математическую логику решения:

  1. Точка начала интервала: Дата ДатаС является началом нового непрерывного интервала, если в таблице *не существует* другой строки с таким же наименованием товара, у которой период перекрывает или встык предшествует текущей дате (то есть нет записи, где Предыдущая.ДатаДо + 1 день >= Наша.ДатаС при Предыдущая.ДатаС < Наша.ДатаС).
  2. Точка окончания интервала: Дата ДатаДо является концом интервала, если для нее *не существует* другой строки, которая начинается встык или внутри этого периода (нет записи, где Следующая.ДатаС - 1 день <= Наша.ДатаДо при Следующая.ДатаДо > Наша.ДатаДо).
  3. Определив все левые границы (Начала) и все правые границы (Концы), мы можем соединить каждое "Начало" с соответствующим ему минимальным "Концом", который больше или равен этому "Началу".

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


// Шаг 1. Помещаем исходные данные во временную таблицу для оптимизации
ВЫБРАТЬ
    Накладные.Наименование КАК Товар,
    Накладные.СрокХраненияС КАК ДатаС,
    Накладные.СрокХраненияДо КАК ДатаДо
ПОМЕСТИТЬ ВТ_ИсходныеДанные
ИЗ
    Документ.Накладные.Наименование КАК Накладные
ГДЕ
    Накладные.Ссылка = &Ссылка

ИНДЕКСИРОВАТЬ ПО
    Товар,
    ДатаС,
    ДатаДо
;

////////////////////////////////////////////////////////////////////////////////
// Шаг 2. Находим все уникальные точки НАЧАЛА непрерывных периодов.
// Это те строки, для которых нет "предыдущих" смежных записей.
ВЫБРАТЬ РАЗЛИЧНЫЕ
    Исходные.Товар КАК Товар,
    Исходные.ДатаС КАК ДатаС
ПОМЕСТИТЬ ВТ_НачалаПериодов
ИЗ
    ВТ_ИсходныеДанные КАК Исходные
ГДЕ
    НЕ ИЗ (
        ВЫБРАТЬ 1 
        ИЗ ВТ_ИсходныеДанные КАК Предыдущие
        ГДЕ Предыдущие.Товар = Исходные.Товар
            // Предыдущий период пересекается или стыкуется день в день с текущим
            И Предыдущие.ДатаС < Исходные.ДатаС
            И ДОБАВИТЬКДАТЕ(Предыдущие.ДатаДо, ДЕНЬ, 1) >= Исходные.ДатаС
    )

ИНДЕКСИРОВАТЬ ПО
    Товар,
    ДатаС
;

////////////////////////////////////////////////////////////////////////////////
// Шаг 3. Находим все уникальные точки ОКОНЧАНИЯ непрерывных периодов.
// Это те строки, для которых нет "последующих" смежных записей.
ВЫБРАТЬ РАЗЛИЧНЫЕ
    Исходные.Товар КАК Товар,
    Исходные.ДатаДо КАК ДатаДо
ПОМЕСТИТЬ ВТ_КонцыПериодов
ИЗ
    ВТ_ИсходныеДанные КАК Исходные
ГДЕ
    НЕ ИЗ (
        ВЫБРАТЬ 1 
        ИЗ ВТ_ИсходныеДанные КАК Следующие
        ГДЕ Следующие.Товар = Исходные.Товар
            // Следующий период пересекается или стыкуется день в день с текущим
            И Следующие.ДатаДо > Исходные.ДатаДо
            И ДОБАВИТЬКДАТЕ(Следующие.ДатаС, ДЕНЬ, -1) <= Исходные.ДатаДо
    )

ИНДЕКСИРОВАТЬ ПО
    Товар,
    ДатаДо
;

////////////////////////////////////////////////////////////////////////////////
// Шаг 4. Сопоставляем каждое "Начало" с ближайшим "Концом" периода.
// Для каждого Начала мы берем минимальный Конец, который больше или равен ему.
ВЫБРАТЬ
    Начала.Товар КАК Товар,
    Начала.ДатаС КАК ДатаС,
    МИНИМУМ(Концы.ДатаДо) КАК ДатаДо
ИЗ
    ВТ_НачалаПериодов КАК Начала
    ВНУТРЕННЕЕ СОЕДИНЕНИЕ ВТ_КонцыПериодов КАК Концы
        ПО Начала.Товар = Концы.Товар
        И Начала.ДатаС <= Концы.ДатаДо
СГРУППИРОВАТЬ ПО
    Начала.Товар,
    Начала.ДатаС
УПОРЯДОЧИТЬ ПО
    Товар,
    ДатаС

Разберем детальнее логику соединения в Шаге 4:

Для каждого найденного легитимного "Начала" периода мы делаем внутреннее соединение со всеми "Концами" этого же товара, которые хронологически находятся позже этого начала (Начала.ДатаС <= Концы.ДатаДо). Сгруппировав результат по "Началу" и выбрав минимальный из всех возможных "Концов" через функцию МИНИМУМ(Концы.ДатаДо), мы гарантированно получаем дату окончания именно текущего непрерывного "острова" дат.

Этот запрос выдаст абсолютно корректно сгруппированную и свернутую таблицу:

Товар ДатаС ДатаДо
Груши 11.07.2019 23.08.2019
Картофель 20.07.2019 16.08.2019
Яблоки 13.07.2019 03.08.2019

Плюсы метода:

---

Резюме и рекомендации

Мы детально изучили две важные темы разработки в 1С:

  1. Левое соединение: Чтобы оно не превращалось во внутреннее при наложении фильтров, всегда выносите условия по правой таблице в конструкцию ПО. Помните, что секция ГДЕ применяется к результату уже после физического слияния таблиц и отсекает записи с NULL значениями.
  2. Свертка смежных периодов: Если перед вами стоит задача объединения периодов, оцените объемы данных. Для небольших локальных обработок ТЗ вполне подойдет простой алгоритмический перебор в коде. Однако для высоконагруженных систем, отчетов СКД или сложных складских/финансовых механизмов лучшим решением будет использование оптимизированного пакета запросов с поиском границ интервалов.
← На главную