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