Как пропорционально распределить сумму

// Выполняет пропорциональное распределение суммы в соответствии // с заданными коэффициентами распределения. // // Параметры: // РаспределяемаяСумма — Число — сумма, которую надо распределить; // МассивКоэффициентов — Массив — коэффициенты распределения; // Точность — Число — точность округления при распределении. Необязателен. // // Возвращаемое значение: // Массив — массив размерностью равный массиву коэффициентов, содержит // суммы в соответствии с весом коэффициента (из массива коэффициентов). // В случае если распределить не удалось (сумма = 0, кол-во коэффициентов = 0, // или суммарный вес коэффициентов = 0), тогда возвращается значение Неопределено. // Функция РаспределитьСуммуПропорциональноКоэффициентам(Знач РаспределяемаяСумма, Коэффициенты, Знач Точность = 2) Экспорт Если Коэффициенты.Количество() = 0 или Не ЗначениеЗаполнено(РаспределяемаяСумма) Тогда Возврат Неопределено; КонецЕсли; ИндексМаксимальногоКоэффициента = 0; МаксимальныйКоэффициент = 0; РаспределеннаяСумма = 0; СуммаКоэффициентов = 0; Для Индекс = 0 По Коэффициенты.Количество() — 1 Цикл Коэффициент = Коэффициенты; АбсолютноеЗначениеКоэффициента = ?(Коэффициент > 0, Коэффициент, -Коэффициент); Если МаксимальныйКоэффициент < АбсолютноеЗначениеКоэффициента Тогда МаксимальныйКоэффициент = АбсолютноеЗначениеКоэффициента; ИндексМаксимальногоКоэффициента = Индекс; КонецЕсли; СуммаКоэффициентов = СуммаКоэффициентов + Коэффициент; КонецЦикла; Если СуммаКоэффициентов = 0 Тогда Возврат Неопределено; КонецЕсли; Результат = Новый Массив(Коэффициенты.Количество()); Для Индекс = 0 По Коэффициенты.Количество() — 1 Цикл Результат = Окр(РаспределяемаяСумма * Коэффициенты / СуммаКоэффициентов, Точность, 1); РаспределеннаяСумма = РаспределеннаяСумма + Результат; КонецЦикла; // Погрешности округления отнесем на коэффициент с максимальным весом. Если Не РаспределеннаяСумма = РаспределяемаяСумма Тогда Результат = Результат + РаспределяемаяСумма — РаспределеннаяСумма; КонецЕсли; Возврат Результат; КонецФункции //в последней строчке делай = общая сумма — уже распределили // Выполняет пропорциональное распределение суммы в соответствии // с заданными коэффициентами распределения. // // Параметры: // РаспределяемаяСумма — Число — сумма, которую надо распределить; // МассивКоэффициентов — Массив — коэффициенты распределения; // Точность — Число — точность округления при распределении. Необязателен. // // Возвращаемое значение: // Массив — массив размерностью равный массиву коэффициентов, содержит // суммы в соответствии с весом коэффициента (из массива коэффициентов). // В случае если распределить не удалось (сумма = 0, кол-во коэффициентов = 0, // или суммарный вес коэффициентов = 0), тогда возвращается значение Неопределено. // Функция РаспределитьСуммуПропорциональноКоэффициентам(Знач РаспределяемаяСумма, Коэффициенты, Знач Точность = 2) Экспорт Если Коэффициенты.Количество() = 0 или Не ЗначениеЗаполнено(РаспределяемаяСумма) Тогда Возврат Неопределено; КонецЕсли; ИндексМаксимальногоКоэффициента = 0; МаксимальныйКоэффициент = 0; РаспределеннаяСумма = 0; СуммаКоэффициентов = 0; Для Индекс = 0 По Коэффициенты.Количество() — 1 Цикл Коэффициент = Коэффициенты; АбсолютноеЗначениеКоэффициента = ?(Коэффициент > 0, Коэффициент, -Коэффициент); Если МаксимальныйКоэффициент < АбсолютноеЗначениеКоэффициента Тогда МаксимальныйКоэффициент = АбсолютноеЗначениеКоэффициента; ИндексМаксимальногоКоэффициента = Индекс; КонецЕсли; СуммаКоэффициентов = СуммаКоэффициентов + Коэффициент; КонецЦикла; Если СуммаКоэффициентов = 0 Тогда Возврат Неопределено; КонецЕсли; Результат = Новый Массив(Коэффициенты.Количество()); Для Индекс = 0 По Коэффициенты.Количество() — 1 Цикл Результат = Окр(РаспределяемаяСумма * Коэффициенты / СуммаКоэффициентов, Точность, 1); РаспределеннаяСумма = РаспределеннаяСумма + Результат; КонецЦикла; // Погрешности округления отнесем на коэффициент с максимальным весом. Если Не РаспределеннаяСумма = РаспределяемаяСумма Тогда Результат = Результат + РаспределяемаяСумма — РаспределеннаяСумма; КонецЕсли; Возврат Результат; КонецФункции //в последней строчке делай = общая сумма — уже распределили

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

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

Допустим, интернет-магазин предоставляет клиенту скидку на заказ (по бонусной карте, купону или ещё как-то). Скидка даётся на весь заказ, но её действие нужно пропорционально распределить по товарам, чтобы, например, правильно сформировать кассовый чек (в нём каждую позицию чека нужно расписать: цена до применения скидки и сумма с учётом скидки). Пример:
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); if ($maxCoefficient < $absCoefficient) { $maxCoefficient = $absCoefficient; $maxCoefficientKey = $keyCoefficient; } $sumCoefficients += $coefficient; } 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 / $sumCoefficients), $precision) + $i * $addStep; // Проверим распределённую сумму коэффициента относительно его количества if (isset($coefficient) && $coefficient > 0) { if (round($result / $coefficient, $precision) != ($result / $coefficient)) { // Не прошли проверку по количеству — ровно по заданному количеству не распределяется } else { $isOk = true; } } else { // Количество не задано, значит не проверяем распределение по количеству $isOk = true; } $i++; if ($i > 100) { // Мы старались долго. Пора признать, что ничего не выйдет throw new Exception( ‘Не удалось распределить сумму для коэффициента ‘ . $keyCoefficient ); } } while (!$isOk); // Если сюда дошли, значит удалось вычислить сумму распределения $arResult = , ‘final’ => (0 === $precision) ? intval($result) : $result, ‘count’ => $coefficient ]; $allocatedAmount += $result; } if ($allocatedAmount != $sum) { // Есть погрешности округления, которые надо куда-то впихнуть $tmpRes = $arResult + $sum — $allocatedAmount; if (!isset($arResult) || (isset($arResult) && 1 === $arResult) || (isset($arResult) && $arResult > 0 && (round($tmpRes / $arResult, $precision) == ($tmpRes / $arResult)) ) ) { // Погрешности округления отнесём на коэффициент с максимальным весом $arResult = (0 === $precision) ? intval($tmpRes) : $tmpRes; } else { // Погрешности округления нельзя отнести на коэффициент с максимальным весом // Надо подыскать другой коэффициент $isOk = false; foreach ($arCoefficients as $keyCoefficient => $coefficient) { if ($keyCoefficient != $maxCoefficientKey) { // Пробуем погрешность округления впихнуть в текущий коэффициент $tmpRes = $arResult + $sum — $allocatedAmount; if (!isset($arResult) || (isset($arResult) && 1 === $arResult) || (isset($arResult) && $arResult > 0 && (round($tmpRes / $arResult, $precision) == ($tmpRes / $arResult)) ) ) { // Погрешности округления отнесём на коэффициент с максимальным весом $arResult = (0 === $precision) ? intval($tmpRes) : $tmpRes; $isOk = true; break; } } } if (!$isOk) { throw new Exception(‘Не удалось распределить погрешность округления’); } } } } return $arResult; } Проверим на тестовых значениях:

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

$arProduct = , ]; $arResult = getProportionalSums(1000, $arProduct, 0); echo ‘<pre>’; print_r($arResult); echo ‘</pre>’; Результат: