Supplier price breaks

Price breaks or volume discounts, represent situations where the margin unit price of goods is varying, depending on the quantity considered for the purchase. Usually, unit prices are decreasing when purchase quantities increase, in order to give the client an incentive to buy more. When suppliers offer price breaks, there is an economic incentive in having purchase quantities correspondingly adjusted to take advantage of those price breaks. Envision offers extensive support for supplier price breaks. In this section, we detail how to model price breaks from the purchasing optimization perspective.

Table representation of price breaks

The most frequent way to represent price breaks for a list of products is to have a table with 3 columns:

In order to keep the table readable, when looking at a given product, the lines are typically sorted by quantities in increasing order. This ordering helps making sense of the magnitude of the discounts.

This representation is prone to negative marginal unit prices where buying one more unit can result in a lower total price.

Let’s consider the product A sold at 1€ per unit. The product A has a price break at 50 units, where the price drops to 0.9€ per unit. Purchasing 46 units costs 46€ while purchasing 50 units only costs 45€. Thus, there is no economic incentive to buy any quantities within the range 46-49 units, as it is cheaper to buy 50 units instead. The marginal unit price of the 50th unit is -4€.

Those negative marginal prices are the consequence of the underlying data model adopted for the price breaks. More elaborate price break models - capable of eliminating those negative marginal prices - exist, however those models are beyond the scope of the present section.

In the following, we assume that the price break data can be made available through a table as detailed above.

pricebrk() function

The purpose of the pricebrk() function in Envision is to transform a tabular price break data into a distribution that represents the marginal purchase cost of units. The syntax is:

// 'Prcs' is the price break table
expect Prcs[Id, *]
B = pricebrk(D, P, Prcs.MinQ, Prcs.P, Stk, StkP)

The function returns the distribution of the marginal purchase unit price, that is, the price to be paid to purchase the kth unit. This function is a bit complex. We detail and justify this complexity in the following.

The arguments are:

The P argument is merely a syntactic sugar intended to deal with situations where the price break table only covers the products that actually benefit from a price break. Through this argument, we avoid the need to extend the table Prcs to have at least one line per product.

Resolution of distributions

A distribution is required as the first argument of pricebrk() because distributions in Envision are not arbitrarily precise. Indeed, there are practical limits to the resolution of a distribution within Envision. Yet, the price breaks obtained from a given supplier can range from a 1 unit purchase to a (theoretical) 10 million unit purchase. The Demand distribution is used by the pricebrk() function in order to adjust the resolution of the returned distribution to the range of interest.

Indeed, the one pitfall that Envision prevents is an inventory optimization logic that ends up suggesting to purchase 999 units while the target price break is at 1000 units. Such a situation could happen if the distribution generated by Envision does not internally differentiate the values at 999 units and 1000 units. By passing a distribution to the pricebrk() function, Envision ensures that this specific scenario is avoided by adapting the resolution of the returned distribution.

Ordering space vs. Stocking space

The price break table is organized from an ordering perspective, associated a unit purchase price to every purchased quantities. However, the inventory optimization perspective is organized from a stocking perspective: when we consider adding +1 unit of stock, we take into account the stock already available, that is, the stocking space. The pricebrk() function translates the price break representation from the original ordering space toward the stocking space.

This translation is the reason why pricebrk() takes two arguments associated with the stock: the stock level and the stock unit price. Those two arguments are used to shift the marginal price distribution to the right by Stock units. The shift could be done with the regular shift operator >> on distributions, but once again, this could trigger situations where minor approximations collide with price break thresholds. The pricebrk() function internalizes this shift in order to eliminate those approximations.

Marginal cost of units

The distribution returned by pricebrk() represents the marginal unit purchase cost. The segment [1;Stk] is associated to current stocks and associated to StkP. Then, if B is the distribution returned by pricebrk(), then the integral int(B, Stk + 1, Stk + N) is the total cost of purchasing N units beyond the units that are already in stock.

As pointed out above, price break tables are frequently associated with negative marginal unit costs - i.e. situations where purchasing one more unit comes with a negative cost. The distributions returned by pricebrk() reflect those situations through local negative values. Those negative values are consistent and the direct consequence of the price break data model.

Combining price breaks and stock rewards

The stock reward function computes the distribution of marginal economic returns for every extra unit of inventory held in stock. In a previous section, we have seen how this function can be associated to economic variables that represent the gross margin, the stock out penalty and the carrying cost. In our previous discussion, those economic variables were plain numbers. However, when price breaks are involved, those economic variables are also varying, along with the quantity being ordered. These variations are straightforward to model through distributions.

B = pricebrk(D, BuyP, Prcs.MinQ, Prcs.P, Stk, StkP)

// 'M', 'S' and 'C' are distributions
M = SellPrice - B
S = -0.5 * (SellPrice - B)
C = -0.3 * B * mean(Leadtime) / 365

AM = 0.3
AC = 1 - 0.2 * mean(Leadtime) / 365

// point-wise multiplication for 'RM', 'RS' and 'RC'
RM = stockrwd.m(D, AM) * M
RS = stockrwd.s(D) * S
RC = stockrwd.c(D, AC) * C
R = RM + RS + RC

The primary difference between this block of code and the original one, when we introduced the stock reward function, is BuyP. The column BuyP is turned into a price break distribution B at line 1 through the function pricebrk(). Then, the rest of script boils down to a direct application of the algebra of distribution, which is doing all the complicated work for us.

At lines 4-6, the economic variables are turned into distributions. When a constant is added to a distribution, the result of the addition is a distribution. The same goes for subtraction. Above M is the margin reward per unit (aka the marginal margin), and as the price breaks are typically offering a lower unit price as quantity increases, the distribution M is expected to be increasing.

At lines 12-14, the components of the stock reward functions are associated with the economic distributions (rather than numbers), but the syntax remains identical. Under the hood, it’s point-wise multiplications that take place between distributions. Finally, at line 15, the final stock reward is composed just as we did it before.