Как в 1С подобрать товары на заданную сумму (решение задачи о рюкзаке при частичной оплате)?

Программист 1С v7.7 Торговля и дистрибуция
← На главную

При автоматизации кассовых операций и расчетов с покупателями часто возникает задача: в документе отгрузки есть табличная часть с товарами (наименование, цена, количество), и поступает частичная оплата на определенную сумму. Требуется набрать товарные позиции так, чтобы их суммарная стоимость была не меньше суммы платежа, но при этом превышение (дельта) оказалось минимальным. В теории алгоритмов это классическая задача об ограниченном рюкзаке (Bounded Knapsack Problem) с ограничением «справа» (минимизация суммы при условии $\sum \ge S$).

Разберем, как реализовать такой подбор программно, какие математические алгоритмы работают быстрее всего и с какими фискальными коллизиями можно столкнуться при печати чеков по 54-ФЗ.

Методологические требования 54-ФЗ: нужен ли построчный подбор товаров?

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

  1. Нарушение баланса чека: В соответствии с форматами фискальных документов (ФФД 1.05, 1.1, 1.2) итоговая сумма по чеку (тег 1020) обязана строго до копейки совпадать с суммой строк и суммой принятых оплат. Если клиент внес 9 450 рублей, а алгоритм подобрал дискретные товары на 9 460 рублей, пробить такой чек напрямую невозможно: касса выдаст ошибку несовпадения сумм либо возникнет расхождение наличных/безналичных средств в фискальном накопителе.
  2. Официальный порядок погашения задолженности: Согласно разъяснениям ФНС, при приеме оплаты за ранее отгруженный товар оформляется чек с признаком способа расчета «ОПЛАТА КРЕДИТА» (значение 7). В этом случае закон не требует принудительно расписывать конкретные товары из накладной. Чек может содержать одну строку с признаком предмета расчета «ПЛАТЕЖ», наименованием вида "Оплата по накладной №...", количеством 1 и суммой, строго равной сумме платежа.
  3. Допустимость дробного количества: Если учетная политика предприятия требует указывать именно номенклатуру, ФФД допускает дробные значения в реквизите «Количество предмета расчета» (тег 1023, до трех знаков после запятой). Это снимает необходимость дискретного перебора комбинаций.

Способ 1. Бизнес-решение: последовательное списание (FIFO с дроблением)

Если требуется вывести в чек реальные позиции накладной, самым надежным и простым методом является последовательный перебор строк с дроблением последней позиции. Сумма чека всегда сойдется копейка в копейку.

Рассмотрим логику работы алгоритма:

  1. Обходим строки документа отгрузки.
  2. Если сумма строки меньше или равна остатку необходимой суммы оплаты, берем позицию целиком.
  3. Если стоимость строки превышает остаток суммы, позиция делится: рассчитывается необходимое дробное количество (ОстатокСуммы / Цена), а копеечная погрешность округления корректируется в итоговой сумме строки.
  4. Расчет завершается ровно тогда, когда набрана точная сумма платежа.

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

Способ 2. Инвертирование задачи (вычитание из общей суммы накладной)

Если требуется строго целочисленное количество единиц товара, а частичная оплата закрывает существенную часть накладной (например, оплачено 90% отгрузки), прямая задача подбора работает медленно из-за огромного пространства комбинаций.

В этом случае эффективнее инвертировать задачу: вместо набора позиций на сумму $S$ мы ищем комбинацию товаров, которую нужно вычесть из накладной на сумму не более $(\text{СуммаВсехТоваров} - S)$.


// Пример инвертирования задачи набора
СуммаВсехТоваров = 0;
Для К = 1 По ТабТоваров.КоличествоСтрок() Цикл
    СуммаВсехТоваров = СуммаВсехТоваров + ТабТоваров.ПолучитьЗначение(К, "Сумма");
КонецЦикла;

ЛишняяСумма = СуммаВсехТоваров - ИскомаяСумма;

Если ЛишняяСумма > 0 Тогда
    // Ищем комбинацию, сумма которой <= ЛишняяСумма (классический рюкзак)
    ТабЛишнихТоваров = ПодобратьРюкзакДо(ЛишняяСумма);
    
    // Вычитаем лишнее из исходной корзины
    Для К = 1 По ТабЛишнихТоваров.КоличествоСтрок() Цикл
        НомСтр = 0;
        Если ТабТоваров.НайтиЗначение(ТабЛишнихТоваров.ПолучитьЗначение(К, "Товар"), НомСтр, "Товар") = 1 Тогда
            ТекКол = ТабТоваров.ПолучитьЗначение(НомСтр, "Количество");
            ВычКол = ТабЛишнихТоваров.ПолучитьЗначение(К, "Количество");
            ТабТоваров.УстановитьЗначение(НомСтр, "Количество", ТекКол - ВычКол);
        КонецЕсли;
    КонецЦикла;
КонецЕсли;

Способ 3. Метод ветвей и границ (Branch and Bound)

Когда требуется найти комбинацию с минимальным превышением заданной суммы, применяется метод ветвей и границ. Чтобы алгоритм укладывался в отведенные 1–2 секунды, применяются правила отсечения неперспективных ветвей перебора:

  1. Сортировка: Исходные позиции сортируются по убыванию цены. Перебор начинается с самых дорогих предметов, что позволяет быстро сформировать базовое приближение к целевой сумме.
  2. Отсечение по рекорду: Если текущая набранная сумма плюс минимальная цена оставшихся товаров уже превышает ранее найденный лучший результат (рекорд), дальнейший спуск по этой ветке дерева немедленно прекращается.
  3. Быстрое завершение ветви: Если текущая сумма плюс минимальный доступный предмет из оставшихся превышает или равна целевой сумме, мы фиксируем этот вариант (он является локальным оптимумом для данной ветки) и прерываем дальнейшее углубление.
  4. Ограничение по числу итераций: Для предотвращения зависания при неудачных сочетаниях цен устанавливается лимит итераций (например, 50 000 шагов). При превышении лимита возвращается наилучший найденный к этому моменту жадный результат.

Способ 4. Динамическое программирование и бинарное разложение количеств

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

Любое целое количество $K$ можно представить в виде суммы степеней двойки: $1, 2, 4, 8, \dots, K - (2^m - 1)$. Например, если салфеток 13 штук, мы разбиваем их на виртуальные «пачки» размером 1, 2, 4 и 6 штук. Из этих пачек можно составить любое число от 1 до 13. Таким образом, строка с тысячами единиц превращается всего в $\approx 10$ независимых элементов, и задача сводится к классическому рюкзаку 0-1.

Далее запускается одномерный массив динамического программирования, где индекс массива — это проверяемая сумма, а значение — признак ее достижимости (или индекс предмета, которым эта сумма была набрана). Поиск идет в диапазоне от ЦелеваяСумма до ЦелеваяСумма + МаксимальнаяЦена. Первая же достижимая сумма в этом диапазоне и будет математически точным решением с минимальной дельтой.

Особенности реализации в «1С:Предприятии 7.7»

При переносе математических алгоритмов на платформу 1С 7.7 важно учитывать специфику ее среды исполнения:

  1. Однопоточность и медленный интерпретатор: Встроенные объекты (СписокЗначений, ТаблицаЗначений) создают ощутимые накладные расходы при частых обращениях в циклах. Перед запуском тяжелого расчета выгрузим данные в простые массивы или типизированные структуры.
  2. Глубина стека вызовов: Рекурсивные вызовы в 7.7 имеют жесткое ограничение системного стека. Во избежание аварийного завершения программы (Stack Overflow) алгоритмы ветвей и границ следует реализовывать через итеративные циклы с собственным массивом состояний вместо глубокой рекурсии.

Применяя бинарное разложение в связке с динамическим программированием либо метод ветвей и границ с отсечением по рекорду, подбор корзины из сотен позиций отрабатывает быстрее 0,5 секунды.

← На главную