Пропорциональное распределение суммы

Есть довольно распространённая потребность в функции, которая могла бы распределять заданную сумму согласно весовым коэффициентам. На первый взгляд, не очень понятно, о чём речь. Приведу примеры.

Распределение скидки на заказ

Допустим, интернет-магазин предоставляет клиенту скидку на заказ (по бонусной карте, купону или ещё как-то). Скидка даётся на весь заказ, но её действие нужно пропорционально распределить по товарам, чтобы, например, правильно сформировать кассовый чек (в нём каждую позицию чека нужно расписать: цена до применения скидки и сумма с учётом скидки).
Пример:
1) Товар №1 - цена 1500 руб.
2) Товар №2 - цена 1700 руб.
Итого сумма заказа получается 3200 руб. Допустим, клиенту предоставляется скидка 10%. В данном случае легко посчитать, что скидка в процентах будет одинаковой для каждого товара:
1) Товар №1 - цена со скидкой 1500 - 10% = 1350 руб.
2) Товар №2 - цена со скидкой 1700 - 10% = 1530 руб.
Это лёгкий пример, где никакого распределения не потребовалось. Теперь изменим условия примера. Допустим, скидка на заказ предоставляется не в процентах, а в рублях, - например, 500 руб. Как её учесть в стоимостях товаров? Вот тут уже требуется распределение. Да, можно было бы скидку целиком вписать в один какой-то товар - но это было бы некрасиво; да к тому же не универсально, ведь все товары в заказе могли бы стоить меньше, чем сумма скидки - что ж теперь отрицательную цену делать?! Нет конечно.
Итак распределяем. Очевидно, что первый товар стоит дешевле, значит, и скидку не него надо сделать меньше, чем на второй товар. Считаем сумму заказа, а потом долю стоимости каждого товара в заказе. Полученную долю умножаем на скидку на заказ.
1) Товар №1 1500 * 100 / 3200 = 46,875% - такова доля стоимости первого товар в общем заказе
2) Товар №2 1700 * 100 / 3200 = 53,125%. Проверим, что мы не ошиблись в округлении и не потеряли какой-нибудь доли заказа: 46,875 + 53,125 = 100% - всё верно.
Скидка распределяется согласно полученным долям:
1) 500 * 46,875 / 100 = 234,375. Получилось не очень-то красивое число. Во-первых, суммы допустимо указывать с копейками, а копейки - это только сотые доли. А тут получились тысячных. Во-вторых, в интернет магазине вообще могут не захотеть иметь дела с копейками. Требуется округление. Приводим к скидке 234 руб. Т.е. цена товара с учётом скидки равна 1500 - 234 = 1266 руб.
2) 500 * 53,125 / 100 = 265,625 - аналогично математическим округлением получаем 266 руб. Цена с учётом скидки 1200 - 266 = 934 руб.
Проверим, все ли 500 рублей мы вписали в виде отдельных скидок по товарам: 234 + 266 = 500 - всё верно.
Это простой пример, который довольно легко поддаётся алгоритмизации и составлению функции. Подобные функции для различных языков мне попадались в интернете. Но есть тут и подвохи, не будь которых - не было бы и статьи.

Подводные камни

Подвоха два:
1. Округление - не всегда округление скидок по товарам в сумме даёт скидку заказа. С этим авторы многих функций решают путём проверки и учёта разницы в последнем или самом дорогом товаре. Алгоритм моей функции навеян функцией отсюда Распределение суммы прапорционально
2. Не все скидки вообще возможно распределить. Это справедливо, когда требуется учитывать ещё и количество товара - т.е. цена товара никогда не должна быть с точностью больше двух десятичных знаков (т.е. копеек), а в большинстве случаев реальных магазинов - вообще без копеек. Т.е. нужна заданная точность. Невозможно ведь иметь три штуки товара в сумме 1000 руб. - тогда каждый из товаров стоил бы 333,(3) (три в периоде) руб. Вот этот момент вообще нигде не нашёл в сети. Автоматическое распределение алгоритмом, как показан выше вполне может выдать такой неделимый результат. Значит алгоритм требует доработки.

Функция распределения

Мой вариант функции (для языка PHP 7) учитывает количество по каждой позиции и заданную точность. Возвращаемый результат - всегда массив с таким же порядком и количеством элементов, что и входящий массив. А все неразрешимые ситуации генерируют исключение. Таким образом функцию не безопасно использовать обычным образом, - требуется организация перехвата исключения и какая-то реакция на исключительное поведение.
Какие могут быть варианты реакций на неразрешимые распределения? Если распределяется некая скидка по купону, то можно пойти на встречу клиенту и в неразрешимой ситуации накинуть рубль или два к скидке - этого вполне может хватить чтобы подыскать ближайший возможный вариант распределения. Потребуется перебор возможных вариантов.
Если это применение бонусов с личного счёта клиента, то неправильно было бы применить больше, чем есть на счёте - тут наоборот нужно подыскать первый доступный вариант с уменьшением скидки (и не забыть пояснить клиенту, что скидка именно такая по математическим и бухгалтерским причинам).
А если, например, первоначальный взнос клиента по кредиту невозможно равномерно распределить по товарам (для печати чека, например), то тут уже надо запрашивать алгоритм действия у бухгалтерии и руководства.
Варианты всегда есть - нужно просто помнить об исключительных ситуациях и адекватно на них реагировать.
Вот сама функция:
/**
 * Метод выполняет пропорциональное распределение суммы в соответствии с заданными коэффициентами распределения.
 * Также может выполняться проверка полного деления суммы коэффициента на его количество. Например,
 * при нулевой точности для чётного количества штук товара было неправильно получить нечётную сумму
 * после распределения, - правильно немного увеличить сумму распределения (в ущерб пропорциональности),
 * чтобы добиться ровного распределения по количеству.
 * Используется, например, при распределении скидки равномерно по позициям корзины.
 * @param float $sum Распределяемая сумма
 * @param array $arCoefficients Массив коэффициентов распределения, где ключи - определённые значения,
 *    которые также будут возвращены в виде ключей результирующего массива. Значения - массив с ключами:
 *    "sum"   - величина коэффициента (сумма, а не цена)
 *    "count" - количество для коэффициента
 * @param int $precision Точность округления при распределении. Если передать 0,
 *    то все суммы после распределения будут целыми числами
 * @throws Exception Выбрасывается исключение в случае,
 *    если невозможно ровно распределить по заданным параметрам
 * @return array Массив, где сохранены ключи исходного массива $arCoefficients, а значения - массив с ключами:
 *    "init"  - начальная сумма, равная соответствующему входному коэффициенту
 *    "final" - сумма после распределения
 */
public static function getProportionalSums(float $sum, array $arCoefficients, int $precision) : array
{
    $arResult = [];

    /**
     * @var float Сумма значений всех коэффициентов
     */
    $sumCoefficients = 0.0;

    /**
     * @var float Значение максимального коэффициента по модулю
     */
    $maxCoefficient = 0.0;

    /**
     * @var mixed Ключ массива для максимального коэффициента по модулю
     */
    $maxCoefficientKey = null;

    /**
     * @var float Распределённая сумма
     */
    $allocatedAmount = 0;

    foreach ($arCoefficients as $keyCoefficient => $coefficient) {
        if (is_null($maxCoefficientKey)) {
            $maxCoefficientKey = $keyCoefficient;
        }

        $absCoefficient = abs($coefficient['sum']);
        if ($maxCoefficient < $absCoefficient) {
            $maxCoefficient = $absCoefficient;
            $maxCoefficientKey = $keyCoefficient;
        }
        $sumCoefficients += $coefficient['sum'];
    }
    if (!empty($sumCoefficients)) {
        /**
         * @var float Шаг, который прибавляем в попытках распределить сумму с учётом количества
         */
        $addStep = (0 === $precision) ? 1 : (1 / pow(10, $precision));

        foreach ($arCoefficients as $keyCoefficient => $coefficient) {
            /**
             * @var boolean Флаг, удалось ли подобрать сумму распределения для текущего коэффициента
             */
            $isOk = false;

            /**
             * @var integer Количество попыток подобрать сумму распределения
             */
            $i = 0;

            // Далее вычисляем сумму распределения с учётом заданного количества
            do {
                $result = round(($sum * $coefficient['sum'] / $sumCoefficients), $precision) + $i * $addStep;

                // Проверим распределённую сумму коэффициента относительно его количества
                if (isset($coefficient['count']) && $coefficient['count'] > 0) {
                    if (round($result / $coefficient['count'], $precision) != ($result / $coefficient['count'])) {
                        // Не прошли проверку по количеству - ровно по заданному количеству не распределяется

                    } else {
                        $isOk = true;
                    }
                } else {
                    // Количество не задано, значит не проверяем распределение по количеству
                    $isOk = true;
                }

                $i++;
                if ($i > 100) {
                    // Мы старались долго. Пора признать, что ничего не выйдет
                    throw new Exception(
                        'Не удалось распределить сумму для коэффициента ' . $keyCoefficient
                    );
                }
            } while (!$isOk);

            // Если сюда дошли, значит удалось вычислить сумму распределения
            $arResult[$keyCoefficient] = [
                'init'  => $coefficient['sum'],
                'final' => (0 === $precision) ? intval($result) : $result,
                'count' => $coefficient['count']
            ];

            $allocatedAmount += $result;
        }

        if ($allocatedAmount != $sum) {
            // Есть погрешности округления, которые надо куда-то впихнуть
            $tmpRes = $arResult[$maxCoefficientKey]['final'] + $sum - $allocatedAmount;
            if (!isset($arResult[$maxCoefficientKey]['count'])
                || (isset($arResult[$maxCoefficientKey]['count']) && 1 === $arResult[$maxCoefficientKey]['count'])
                || (isset($arResult[$maxCoefficientKey]['count'])
                    && $arResult[$maxCoefficientKey]['count'] > 0
                    && (round($tmpRes / $arResult[$maxCoefficientKey]['count'], $precision) == ($tmpRes / $arResult[$maxCoefficientKey]['count']))
                )
            ) {
                // Погрешности округления отнесём на коэффициент с максимальным весом
                $arResult[$maxCoefficientKey]['final'] = (0 === $precision) ? intval($tmpRes) : $tmpRes;
            } else {
                // Погрешности округления нельзя отнести на коэффициент с максимальным весом
                // Надо подыскать другой коэффициент
                $isOk = false;
                foreach ($arCoefficients as $keyCoefficient => $coefficient) {
                    if ($keyCoefficient != $maxCoefficientKey) {
                        // Пробуем погрешность округления впихнуть в текущий коэффициент
                        $tmpRes = $arResult[$keyCoefficient]['final'] + $sum - $allocatedAmount;
                        if (!isset($arResult[$keyCoefficient]['count'])
                            || (isset($arResult[$keyCoefficient]['count']) && 1 === $arResult[$keyCoefficient]['count'])
                            || (isset($arResult[$keyCoefficient]['count'])
                                && $arResult[$keyCoefficient]['count'] > 0
                                && (round($tmpRes / $arResult[$keyCoefficient]['count'], $precision) == ($tmpRes / $arResult[$keyCoefficient]['count']))
                            )
                        ) {
                            // Погрешности округления отнесём на коэффициент с максимальным весом
                            $arResult[$keyCoefficient]['final'] = (0 === $precision) ? intval($tmpRes) : $tmpRes;
                            $isOk = true;
                            break;
                        }
                    }
                }
                if (!$isOk) {
                    throw new Exception('Не удалось распределить погрешность округления');
                }
            }
        }
    }

    return $arResult;
}
Проверим на тестовых значениях:

Пример, где всё распределяется без дробной части:


$arProduct = [
    [
        'sum'   => 1000,
        'count' => 1 
    ],
    [
        'sum'   => 2000,
        'count' => 2
    ]
];
$arResult = getProportionalSums(1000, $arProduct, 0);
echo '<pre>';
print_r($arResult);
echo '</pre>';
Результат:
Array
(
    [0] => Array
        (
            [init] => 1000
            [final] => 332
            [count] => 1
        )

    [1] => Array
        (
            [init] => 2000
            [final] => 668
            [count] => 2
        )

)

Пример, где невозможно распределить без дробной части:


$arProduct = [
    [
        'sum'   => 1000,
        'count' => 3 
    ],
    [
        'sum'   => 2000,
        'count' => 3
    ]
];
$arResult = getProportionalSums(1111, $arProduct, 0);
echo '<pre>';
print_r($arResult);
echo '</pre>';
Результат:

Комментарии

Популярные сообщения из этого блога

Битрикс: своя геолокация

Bitrix24 API - разбор демо приложения третьего типа