Как объединить повторяющиеся документы по общему реквизиту в 1С 8.3

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

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

Проанализируем ситуацию, разберем типичные ошибки при разработке подобного алгоритма и рассмотрим эффективные варианты реализации объединения данных средствами платформы 1С:Предприятие 8.3.

Анализ типового алгоритма «на скорую руку» и скрытые ошибки

Когда разработчику требуется быстро решить задачу объединения, часто создается алгоритм на основе выгрузки данных в таблицу значений с последующим поиском через НайтиСтроки(). Посмотрим на пример подобной реализации:


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

Выясним причину, почему такой код содержит критические уязвимости:

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

Оптимизация выборки дублей средствами языка запросов

Рассмотрим более производительный подход. Вместо выгрузки всего массива документов в память и использования метода НайтиСтроки() отберем дублирующие документы непосредственно на уровне СУБД с помощью предложений СГРУППИРОВАТЬ ПО и ИМЕЮЩИЕ. Кроме того, функции МИНИМУМ() и МАКСИМУМ() избавят нас от необходимости вручную вычислять граничные даты в цикле.

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


Запрос = Новый Запрос;
Запрос.Текст = 
    "ВЫБРАТЬ
    |    Случай.Пациент КАК Пациент,
    |    МИНИМУМ(Случай.ДатаНачала) КАК ДатаНачалаМинимум,
    |    МАКСИМУМ(Случай.ДатаОкончания) КАК ДатаОкончанияМаксимум,
    |    КОЛИЧЕСТВО(Случай.Ссылка) КАК КоличествоДокументов
    |ПОМЕСТИТЬ ВТ_Дубли
    |ИЗ
    |    Документ.Случай КАК Случай
    |ГДЕ
    |    Случай.ДатаНачала МЕЖДУ &ДатаНачала И &ДатаОкончания
    |    И НЕ Случай.ПометкаУдаления
    |СГРУППИРОВАТЬ ПО
    |    Случай.Пациент
    |ИМЕЮЩИЕ
    |    КОЛИЧЕСТВО(Случай.Ссылка) > 1
    |;
    |
    |////////////////////////////////////////////////////////////////////////////////
    |ВЫБРАТЬ
    |    ВТ_Дубли.Пациент КАК Пациент,
    |    ВТ_Дубли.ДатаНачалаМинимум КАК ДатаНачала,
    |    ВТ_Дубли.ДатаОкончанияМаксимум КАК ДатаОкончания,
    |    Случай.Ссылка КАК ДокументСсылка
    |ИЗ
    |    ВТ_Дубли КАК ВТ_Дубли
    |    ВНУТРЕННЕЕ СОЕДИНЕНИЕ Документ.Случай КАК Случай
    |    ПО ВТ_Дубли.Пациент = Случай.Пациент
    |        И (Случай.ДатаНачала МЕЖДУ &ДатаНачала И &ДатаОкончания)
    |        И (НЕ Случай.ПометкаУдаления)
    |ИТОГИ ПО
    |    Пациент";

Использование обхода по группировкам через ВыборкаПациент = Результат.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам) позволит организовать понятный двухуровневый цикл без промежуточных коллекций и поиска соответствий в памяти.

Архитектурная стратегия «Ведущего документа»

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

Преимущества такой стратегии:

  1. Сохраняется уникальный идентификатор (UUID) ведущего документа, что защищает внешние интеграции и ссылки.
  2. Табличные части можно объединять пакетно с помощью метода ТабличнаяЧасть.Загрузить(), что работает значительно быстрее построчного заполнения методом Добавить().
  3. Для подчиненных документов выполняется корректная пометка на удаление с отменой движений по регистрам.

Итоговый надежный алгоритм объединения

Соберем решение воедино, добавив управление транзакциями и свойство ОбменДанными.Загрузка, которое позволяет обойти лишние проверки в обработчиках событий при массовой обработке:


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

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

← На главную