Как реализовать выбор и учет товаров по срокам годности в расходной накладной при подготовке к экзамену «1С:Специалист»

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

При подготовке к аттестации «1С:Специалист по платформе» (раздел бухгалтерского учета, задачи типа 2.11 и 2.13) соискатели часто сталкиваются со сложной методической задачей. Необходимо организовать учет товаров в разрезе сроков годности на бухгалтерском регистре, реализовать интерактивный выбор только реально существующих на остатках сроков годности в расходной накладной, а также обеспечить корректный отложенный расчет себестоимости с помощью регламентной обработки.

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

Методологический анализ требований технического задания

Давайте внимательно проанализируем ключевые требования технического задания:

  1. Учет в разрезе сроков годности. Срок годности — это конкретная дата. Разные партии одного товара с разными сроками могут иметь разную цену поступления.
  2. Выбор срока годности в Расходной накладной. Пользователь должен указывать срок годности непосредственно в табличной части документа. Чтобы избежать ошибок ввода, программа должна предлагать только те сроки годности конкретного товара, которые реально есть на остатках.
  3. Отложенный расчет себестоимости. При проведении документа «Расходная накладная» себестоимость не должна рассчитываться. Проводка по списанию себестоимости (Дт Прибыли и убытки - Кт Товары) должна содержать только количество, а сумма должна оставаться нулевой. Полный расчет сумм себестоимости по средневзвешенной стоимости в разрезе сроков годности выполняется позже специальной обработкой «Формирование себестоимости».
  4. Учет ручных операций. Поскольку пользователь может вводить произвольные проводки документом «Операция», мы не имеем права дублировать учет на регистрах накопления. Все остатки для контроля и расчетов мы обязаны получать исключительно из регистра бухгалтерии.

Шаг 1: Проектирование структуры метаданных и субконто

Для начала настроим аналитический учет. Нам понадобится субконто для хранения сроков годности.

Создание субконто: Некоторые разработчики используют примитивный тип Дата непосредственно в качестве типа значения субконто в Плане видов характеристик (ПВХ). Однако правильным архитектурным решением считается создание отдельного справочника СрокиГодности. В самом справочнике достаточно иметь реквизит ДатаГодности (тип Дата) и настроить шаблон наименования для удобного вывода на форму. Это позволит в будущем расширять аналитику и обеспечит оптимальную индексацию в СУБД.

Настройка Плана счетов: На счете Товары (активный счет) мы должны определить два уровня субконто:

  1. Номенклатура (первое субконто, тип: СправочникСсылка.Номенклатура). Признаки: Количественный, Суммовой.
  2. СрокиГодности (второе субконто, тип: СправочникСсылка.СрокиГодности). Признаки: Количественный, Суммовой (так как по ТЗ себестоимость определяется как средняя по номенклатурной позиции в разрезе срока годности).

Шаг 2: Реализация интерактивного выбора срока годности на форме Расходной накладной

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

Откроем форму документа «Расходная накладная», выделим поле СрокГодности в табличной части и создадим событие НачалоВыбора. Напишем следующий код:


&НаКлиенте
Процедура СписокНоменклатурыСрокГодностиНачалоВыбора(Элемент, ДанныеВыбора, СтандартнаяОбработка, Источник)
    
    СтандартнаяОбработка = Ложь;
    ТекущаяСтрока = Элементы.СписокНоменклатуры.ТекущиеДанные;
    
    Если ТекущаяСтрока = Неопределено Тогда
        Возврат;
    КонецЕсли;
    
    // Проверим, заполнена ли номенклатура
    Если Не ЗначениеЗаполнено(ТекущаяСтрока.Номенклатура) Тогда
        ПоказатьПредупреждение(, "Сначала необходимо выбрать номенклатуру!");
        Возврат;
    КонецЕсли;
    
    // Получаем список доступных сроков годности с сервера
    СписокСроков = ПолучитьДоступныеСрокиГодностиНаСервере(ТекущаяСтрока.Номенклатура, Объект.Дата);
    
    // Открываем список выбора для пользователя
    Если СписокСроков.Количество() = 0 Тогда
        ПоказатьПредупреждение(, "Нет доступных остатков по данной номенклатуре!");
    Иначе
        ВыбранноеЗначение = ВыбратьИзСписка(СписокСроков, Элемент);
        Если ВыбранноеЗначение <> Неопределено Тогда
            ТекущаяСтрока.СрокГодности = ВыбранноеЗначение.Значение;
        КонецЕсли;
    КонецЕсли;
    
КонецПроцедуры

Теперь реализуем серверную функцию, которая выполнит запрос к виртуальной таблице остатков регистра бухгалтерии на указанный момент времени:


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

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

Шаг 3: Проведение документа «Расходная накладная» и разбор критических ошибок

Рассмотрим код проведения, который часто пишут новички (пример приведен в Сообщении 23), и проанализируем допущенные там ошибки.

Анализ ошибок кода из Сообщения 23:

  1. Попытка списания себестоимости на лету. Автор пытается рассчитать СуммаОстатокСклад и записать проводку списания стоимости сразу при проведении Расходной накладной. По условиям ТЗ этого делать категорически нельзя. Расходная накладная должна провести только количество, сумма по Дт Прибыли и убытки и Кт Товары должна остаться нулевой. Расчетом займется обработка.
  2. Логическая ошибка обхода выборки. В коде автора используется метод РезультатЗапроса.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам). Однако в самом тексте запроса полностью отсутствует предложение ИТОГИ ПО. Из-за этого иерархический обход ломается, и во второй (вложенный) цикл детальных записей система просто не заходит. Программа не формирует никаких движений.
  3. Блокировка данных. Использование автоматического источника данных для блокировки — это правильный шаг, но его нужно настроить на реальные поля табличной части, не забывая о разделении по субконто.

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


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

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

Шаг 4: Алгоритм работы обработки «Формирование себестоимости»

Теперь разберем, как должна работать регламентная обработка «Формирование себестоимости». Ее задача — обойти все расходные документы за выбранный период и рассчитать себестоимость списания по средневзвешенной схеме.

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

  1. Запросом выбираются все проведенные документы «Расходная накладная» за указанный период, упорядоченные по моменту времени.
  2. Внутри транзакции мы последовательно перебираем каждый документ.
  3. Для каждого документа мы получаем остатки товаров на его Момент времени.
  4. Важный нюанс: Так как наш документ на этот момент времени уже провел свои количественные движения, виртуальная таблица остатков вернет нам остаток после списания количества. Поэтому формула расчета средней себестоимости единицы будет иметь следующий вид:

СредняяСтоимость = СуммаОстаток / (КоличествоОстаток + КоличествоСписаноТекущимДокументом)

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


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

// Вспомогательная функция округления стоимости
Функция Округление(Значение, Разрядность)
    Возврат Окр(Значение, Разрядность, РежимОкругления.Окр15как20);
КонецФункции

Преимущества выбранного подхода

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

← На главную