Как правильно реализовать поупаковочный контроль отгрузки товаров в 1С:УНФ с помощью регистров накопления

Программист 1С v8.3 (Управляемые формы) 1С:Управление нашей фирмой Управленческий учет Торговля и дистрибуция
← На главную

В практической деятельности автоматизации торговых и производственных предприятий часто возникает задача детального контроля отгрузки сложной или крупногабаритной продукции. Классический пример — продажа мебели. В документе продажи (например, в Расходной накладной) указывается одна позиция готовой продукции (например, "Диван угловой"), но фактически со склада этот диван отгружается в виде нескольких отдельных грузовых мест или упаковок (коробка с каркасом, пакет с мягкими подушками, коробка с фурнитурой). Чтобы кладовщик при отгрузке не забыл выдать клиенту одну из коробок, необходимо организовать поупаковочный контроль.

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

---

Проектирование архитектуры решения

Для реализации нашей задачи потребуется создать новый регистр накопления остатков. Назовем его АМ_ТоварыКОтгрузке. Прежде чем переходить к написанию кода, определим оптимальную структуру его измерений и ресурсов:

  1. Получатель (тип: СправочникСсылка.Контрагенты) — измерение, необходимое для анализа того, какому именно клиенту предназначается груз.
  2. ДокументОтгрузки (тип: ДокументСсылка.РасходнаяНакладная) — ключевое измерение. Оно будет выступать в роли "ключа" или заказа, по которому мы фиксируем обязательства склада выдать товар. Именно в разрезе этого документа мы будем контролировать остатки упаковок к отгрузке.
  3. Номенклатура (тип: СправочникСсылка.Номенклатура) — позиция готовой продукции.
  4. Характеристика (тип: СправочникСсылка.ХарактеристикиНоменклатуры) — для учета разрезов свойств продукции.
  5. Упаковка (тип: СправочникСсылка.АМ_УпаковкиПродукции) — подчиненный справочник, в котором хранятся составляющие части нашей номенклатуры (например, "Коробка 1 из 3").
  6. КОтгрузке (тип: Число, длина 15, точность 3) — единственный ресурс регистра, отражающий количество упаковок, которое требуется отгрузить (по приходу) или которое уже фактически выдано (по расходу).

Логика работы системы будет следующей: при проведении документа Расходная накладная система делает движение Приход по нашему регистру, разворачивая каждую позицию номенклатуры до перечня ее физических упаковок. Затем на основании накладной создается специализированный документ Отгрузка товаров, в котором кладовщик сканирует штрихкоды упаковок. При его проведении регистрируется движение Расход.

---

Анализ и исправление логических ошибок при проведении "Прихода"

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


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

Разберем детально, почему этот запрос таит в себе серьезные архитектурные и логические проблемы:

  1. Проблема дублирования строк при ЛЕВОМ СОЕДИНЕНИИ (LEFT JOIN): Если в справочнике АМ_УпаковкиПродукции для одного дивана создано три упаковки (например: Каркас, Подлокотники, Подушки), то левое соединение вернет ровно три строки. При этом в поле КОтгрузке для каждой строки запишется исходное количество диванов из накладной. Например, если в документе продается 2 дивана, то после соединения мы получим:
    • Упаковка "Каркас" — 2 шт.
    • Упаковка "Подлокотники" — 2 шт.
    • Упаковка "Подушки" — 2 шт.
    Это корректно только в том случае, если на один диван строго приходится ровно по одной упаковке каждого типа. А если для дивана требуется 2 упаковки подлокотников или 4 подушки? Исходный запрос не учитывает вложенность и коэффициент упаковок.
  2. Проблема товаров без упаковок: Если в табличную часть Расходной накладной попадет обычный товар, у которого нет записей в справочнике АМ_УпаковкиПродукции, левое соединение вернет значение NULL в поле Упаковка. Попытка записать NULL в измерение регистра накопления приведет к ошибке проведения или некорректному поведению при анализе остатков. Нам необходимо отсекать такие товары или обрабатывать NULL с заменой на пустую ссылку.
  3. Неоптимальный обход выборки: Выгрузка результата запроса в таблицу значений с последующим перебором строк циклом — это лишний расход оперативной памяти сервера. Гораздо эффективнее обходить выборку запроса напрямую.

---

Оптимизация кода проведения "Прихода"

Выяснив причины потенциальных ошибок, перейдем к их исправлению. Сделаем доработку в расширении конфигурации. Для корректного расчета количества упаковок добавим в подчиненный справочник АМ_УпаковкиПродукции реквизит КоличествоВУпаковке (Число, отражающее, сколько штук данной упаковки входит в одну единицу готовой продукции).

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


Процедура ПередЗаписью(Отказ, РежимЗаписи, РежимПроведения)
    // Обязательно взводим флаг записи движений для нашего регистра
    Движения.АМ_ТоварыКОтгрузке.Записывать = Истина;
КонецПроцедуры

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

Проанализируем преимущества обновленного кода:

---

Логика расхода и заполнение остатков при вводе на основании

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

Разберем по шагам логику, которую мы напишем в модуле объекта документа Отгрузка товаров в процедуре ОбработкаЗаполнения:


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

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

---

Управляемые блокировки при проведении расхода

Один из самых важных моментов, который часто упускают начинающие разработчики в 1С — это организация параллельной работы пользователей. Рассмотрим ситуацию: у нас есть крупная Расходная накладная на поставку 10 диванов. На складе работают два кладовщика с терминалами сбора данных. Они одновременно начинают сканировать и грузить упаковки по одной и той же накладной.

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

Давайте выясним причину и установим исключительную блокировку на данные регистра в разрезе нашего документа отгрузки. Этот код мы помещаем в процедуру ОбработкаПроведения документа Отгрузка товаров перед выполнением запроса контроля остатков:


Процедура ОбработкаПроведения(Отказ, РежимПроведения)
    
    Движения.АМ_ТоварыКОтгрузке.Записывать = Истина;
    Движения.АМ_ТоварыКОтгрузке.Очистить();
    
    // Блокируем данные по нашему документу отгрузки на время транзакции проведения
    Блокировка = Новый БлокировкаДанных;
    ЭлементБлокировки = Блокировка.Добавить("РегистрНакопления.АМ_ТоварыКОтгрузке");
    ЭлементБлокировки.Режим = РежимБлокировкиДанных.Исключительный;
    ЭлементБлокировки.УстановитьЗначение("ДокументОтгрузки", ЭтотОбъект.ДокументОснование);
    Блокировка.Заблокировать();
    
    // Теперь мы гарантированно защищены от параллельного изменения остатков другими пользователями.
    // Выполняем запрос для проверки превышения остатков...
    Запрос = Новый Запрос;
    Запрос.Текст = 
    "ВЫБРАТЬ
    |   ДокументТовары.Номенклатура КАК Номенклатура,
    |   ДокументТовары.Характеристика КАК Характеристика,
    |   ДокументТовары.Упаковка КАК Упаковка,
    |   СУММА(ДокументТовары.Количество) КАК Количество
    |ПОМЕСТИТЬ ВТДокумент
    |ИЗ
    |   Документ.ОтгрузкаТоваров.Товары КАК ДокументТовары
    |ГДЕ
    |   ДокументТовары.Ссылка = &Ссылка
    |
    |СГРУППИРОВАТЬ ПО
    |   ДокументТовары.Номенклатура,
    |   ДокументТовары.Характеристика,
    |   ДокументТовары.Упаковка
    |;
    |
    |////////////////////////////////////////////////////////////////////////////////
    |ВЫБРАТЬ
    |   ВТДокумент.Номенклатура КАК Номенклатура,
    |   ВТДокумент.Характеристика КАК Характеристика,
    |   ВТДокумент.Упаковка КАК Упаковка,
    |   ВТДокумент.Количество КАК КоличествоВДокументе,
    |   ЕСТЬNULL(АМ_ТоварыКОтгрузкеОстатки.КОтгрузкеОстаток, 0) КАК ОстатокНаСкладе
    |ИЗ
    |   ВТДокумент КАК ВТДокумент
    |   ЛЕВОЕ СОЕДИНЕНИЕ РегистрНакопления.АМ_ТоварыКОтгрузке.Остатки(
    |       ,
    |       ДокументОтгрузки = &ДокументОтгрузки
    |   ) КАК АМ_ТоварыКОтгрузкеОстатки
    |   ПО ВТДокумент.Номенклатура = АМ_ТоварыКОтгрузкеОстатки.Номенклатура
    |       И ВТДокумент.Характеристика = АМ_ТоварыКОтгрузкеОстатки.Характеристика
    |       И ВТДокумент.Упаковка = АМ_ТоварыКОтгрузкеОстатки.Упаковка";
    
    Запрос.УстановитьПараметр("Ссылка", ЭтотОбъект.Ссылка);
    Запрос.УстановитьПараметр("ДокументОтгрузки", ЭтотОбъект.ДокументОснование);
    
    Выборка = Запрос.Выполнить().Выбрать();
    
    Пока Выборка.Следующий() Цикл
        
        Если Выборка.КоличествоВДокументе > Выборка.ОстатокНаСкладе Тогда
            
            Сообщение = Новый СообщениеПользователю;
            Сообщение.Текст = СтрШаблон(
                "Недостаточно упаковок на складе по документу основания! Требуется: %1, Осталось отгрузить: %2. Товар: %3, Упаковка: %4",
                Выборка.КоличествоВДокументе,
                Выборка.ОстатокНаСкладе,
                Выборка.Номенклатура,
                Выборка.Упаковка
            );
            Сообщение.Сообщить();
            Отказ = Истина;
            
        КонецЕсли;
        
        Если Не Отказ Тогда
            // Если все проверки пройдены — пишем движение расхода
            Движение = Движения.АМ_ТоварыКОтгрузке.Добавить();
            Движение.ВидДвижения = ВидДвиженияНакопления.Расход;
            Движение.Период = ЭтотОбъект.Дата;
            Движение.Получатель = ЭтотОбъект.Контрагент;
            Движение.ДокументОтгрузки = ЭтотОбъект.ДокументОснование;
            Движение.Номенклатура = Выборка.Номенклатура;
            Движение.Характеристика = Выборка.Характеристика;
            Движение.Упаковка = Выборка.Упаковка;
            Движение.КОтгрузке = Выборка.КоличествоВДокументе;
        КонецЕсли;
        
    КонецЦикла;
    
КонецПроцедуры

Разберем, как работает этот защитный механизм:

---

Как аналогичные задачи решаются в типовом функционале 1С

Перед тем как приступать к разработке собственных регистров, всегда полезно изучить, как подобные бизнес-процессы спроектировали разработчики самой фирмы "1С" в типовых решениях. В современных версиях 1С:УНФ 3.0 и ERP-системах контроль упаковки реализован через следующие механизмы:

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

← На главную