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