Пропорциональное распределение суммы
Есть довольно распространённая потребность в функции, которая могла бы распределять заданную сумму согласно весовым коэффициентам. На первый взгляд, не очень понятно, о чём речь. Приведу примеры.
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) (три в периоде) руб. Вот этот момент вообще нигде не нашёл в сети. Автоматическое распределение алгоритмом, как показан выше вполне может выдать такой неделимый результат. Значит алгоритм требует доработки.
Какие могут быть варианты реакций на неразрешимые распределения? Если распределяется некая скидка по купону, то можно пойти на встречу клиенту и в неразрешимой ситуации накинуть рубль или два к скидке - этого вполне может хватить чтобы подыскать ближайший возможный вариант распределения. Потребуется перебор возможных вариантов.
Если это применение бонусов с личного счёта клиента, то неправильно было бы применить больше, чем есть на счёте - тут наоборот нужно подыскать первый доступный вариант с уменьшением скидки (и не забыть пояснить клиенту, что скидка именно такая по математическим и бухгалтерским причинам).
А если, например, первоначальный взнос клиента по кредиту невозможно равномерно распределить по товарам (для печати чека, например), то тут уже надо запрашивать алгоритм действия у бухгалтерии и руководства.
Варианты всегда есть - нужно просто помнить об исключительных ситуациях и адекватно на них реагировать.
Вот сама функция:
Распределение скидки на заказ
Допустим, интернет-магазин предоставляет клиенту скидку на заказ (по бонусной карте, купону или ещё как-то). Скидка даётся на весь заказ, но её действие нужно пропорционально распределить по товарам, чтобы, например, правильно сформировать кассовый чек (в нём каждую позицию чека нужно расписать: цена до применения скидки и сумма с учётом скидки).
Пример: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>';
Результат:
Комментарии
Отправить комментарий