Как правильно настроить контроль остатков товаров в разрезе договоров и складов из табличной части в 1С?

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

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

В этой статье мы подробно разберем, почему возникает эта ошибка, проанализируем неверный код и рассмотрим два варианта решения: классическую ("старую") методику проведения и современную ("новую") методику проведения, которая является стандартом на платформе 1С:Предприятие 8.3.

---

Анализ ошибок в исходном коде

Рассмотрим, почему у автора вопроса контроль остатков по договорам работал некорректно. Основная причина кроется в тексте запроса, а именно в левом соединении табличной части документа с виртуальной таблицей остатков:


ЛЕВОЕ СОЕДИНЕНИЕ РегистрНакопления.ОстаткиТоваров.Остатки( , Номенклатура В (...) ) КАК ОстаткиТоваровОстатки
ПО ДокТЧ.Номенклатура = ОстаткиТоваровОстатки.Номенклатура

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

Вторая логическая ошибка — это избыточный цикл распределения по договорам:


Пока ОсталосьСписать > 0 И ВыборкаДоговор.Следующий() Цикл
    Списать = Мин(ОсталосьСписать, ВыборкаДоговор.КоличествоОстаток);
    ...
КонецЦикла;

Такой цикл (по принципу FIFO) необходим только тогда, когда пользователь в документе указывает просто номенклатуру, а программа должна сама распределить списание по имеющимся остаткам договоров. Если же договор жестко выбран пользователем в каждой строке табличной части, никакого автоматического распределения быть не должно. Нам нужно лишь проверить конкретную строку: "Требуется по Договору А — 10 шт., в наличии по Договору А — 5 шт. -> Отказ".

---

Решение 1. Исправление классической методики проведения

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

Выполним это по шагам:

Шаг 1. Проверка структуры регистра. Убедимся, что в регистре накопления ОстаткиТоваров измерение Договор (и Склад, если он используется в ТЧ) действительно является измерением, а не реквизитом. Накопление остатков возможно только в разрезе измерений.

Шаг 2. Использование управляемых блокировок. Без блокировок в многопользовательской среде несколько пользователей смогут одновременно выписать один и тот же товар, что приведет к появлению отрицательных остатков (проблема "Race Condition"). Наложим блокировку перед выполнением запроса:


Блокировка = Новый БлокировкаДанных;
ЭлементБлокировки = Блокировка.Добавить("РегистрНакопления.ОстаткиТоваров");
ЭлементБлокировки.Режим = РежимБлокировкиДанных.Исключительный;
ЭлементБлокировки.ИсточникДанных = Товары; // Наша табличная часть
ЭлементБлокировки.ИспользоватьИзИсточникаДанных("Номенклатура", "Номенклатура");
ЭлементБлокировки.ИспользоватьИзИсточникаДанных("Договор", "Договор");
// Если склад также указан в ТЧ документа:
ЭлементБлокировки.ИспользоватьИзИсточникаДанных("Склад", "Склад");
Блокировка.Заблокировать();

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


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

Запрос.УстановитьПараметр("Ссылка", Ссылка);
// Для контроля оперативного проведения используем неопределенную дату (пустой момент времени),
// чтобы получить самые актуальные остатки на текущий момент.
Запрос.УстановитьПараметр("Период", Новый Граница(МоментВремени(), ВидГраницы.Исключая));
РезультатЗапроса = Запрос.Выполнить();

Шаг 4. Упрощение логики обхода результата. Поскольку списание должно происходить строго по указанному договору, мы убираем вложенный цикл. Нам достаточно сделать один плоский обход выборки:


Выборка = РезультатЗапроса.Выбрать();
Пока Выборка.Следующий() Цикл
    
    // Проверяем, хватает ли остатка по конкретному договору
    Если Выборка.Количество > Выборка.КоличествоОстаток Тогда
        
        Сообщение = Новый СообщениеПользователю;
        Сообщение.Текст = Шаблон(
            "Не хватает товара по номенклатуре %1 (договор: %2). Требуется: %3, в наличии: %4",
            Выборка.НоменклатураПредставление,
            Выборка.ДоговорПредставление,
            Выборка.Количество,
            Выборка.КоличествоОстаток
        );
        Сообщение.Сообщить();
        Отказ = Истина;
        
    КонецЕсли;
    
    // Если ошибок нет, формируем движения
    Если Не Отказ Тогда
        
        Движение = Движения.ОстаткиТоваров.ДобавитьРасход();
        Движение.Период = Дата;
        Движение.Номенклатура = Выборка.Номенклатура;
        Движение.Договор = Выборка.Договор;
        Движение.Количество = Выборка.Количество;
        
        // Расчет себестоимости списания (при наличии суммового учета)
        Если Выборка.КоличествоОстаток > 0 Тогда
            Движение.Сумма = Выборка.Количество / Выборка.КоличествоОстаток * Выборка.СуммаОстаток;
        Иначе
            Движение.Сумма = Выборка.Сумма; // или иная логика при отсутствии остатка
        КонецЕсли;
        
    КонецЕсли;
    
КонецЦикла;

---

Решение 2. Современный стандарт: "Новая методика" проведения

Классический подход "сначала проверяем, потом записываем" имеет существенный недостаток — двойное чтение данных. Сначала мы выполняем тяжелый запрос к остаткам для проверки, затем записываем движения, что снова вызывает пересчет итогов в СУБД. В высоконагруженных системах это сильно снижает производительность.

Разберем современную "новую методику" проведения (проведение "от обратного"):

  1. Записываем движения документа в регистр "вслепую" (без проверок).
  2. Одним запросом проверяем, не появились ли после нашей записи в регистре отрицательные остатки по позициям нашего документа.
  3. Если обнаружились строки с остатком меньше нуля, выдаем сообщение об ошибке, устанавливаем Отказ = Истина, транзакция откатывается, и движения аннулируются автоматически.

Посмотрим на практическую реализацию новой методики в обработчике проведения:


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

Преимущества использования новой методики:

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

← На главную