# MOQs and other ordering constraints

Probabilistic forecasts offer the possibility to generate a purchase priority list where each incremental unit to be purchased is ranked against its business drivers, such as the expected gross margin and the expected inventory carrying cost. However MOQ (minimal order quantities) introduce non-linear constraints that complicates the calculation of the purchase order quantities. In order to cope with this requirement frequently encountered in supply chains, Lokad has designed a numerical solver precisely intended for MOQs. The solver also addresses the situation where MOQs are combined with a container constraint.

## The `solve.moq`

call function

The MOQ solver is a specialized numerical solver that can be accessed within Envision as a *call function*. The mathematical reference framework is the general MOQ problem, which represents an integer programming problem. The syntax for this function is illustrated below.

```
G.Buy = solve.moq(
Item: Id
Quantity: G.Min
Reward: G.Reward
Cost: G.Cost
// Provide one of these three:
MaxCost: maxBudget
MaxTarget: maxTarget
MinTarget: minTarget
// Optional:
Target: G.Target
TargetGroup: Supplier
// Optional, but must have
// the same number for each, max 8
GroupId: A, B
GroupQuantity: G.A, G.B
GroupMinQuantity: AMoq, BMoq)
```

The parameters are as follows:

`G`

: the*grid*, a table typically obtained through*extend.distrib()*.`Item`

: the identifiers of the SKUs or products relevant for the MOQ optimization.`Quantity`

: the grid quantity, used for ordering the lines of the grid.`Reward`

: the economical reward associated with purchasing the line of the grid.`Cost`

: the economical cost of purchasing the line of the grid.`MaxCost`

: threshold for one of the three optimization modes for the solver. The`MaxCost`

indicates that grid lines will be taken until the budget is exhausted, and no more lines can be added without exceeding the budget.`MaxTarget`

: idem. When used, the target is reached from below; no more grid lines can be added without exceeding the target.`MinTarget`

: idem. When used, the target is reached from above; no more grid lines can be added without getting below the target.`Target`

: the target contribution associated to the grid line. Apply only when either`MaxTarget`

or`MinTarget`

are specified.`TargetGroup`

: when provided, a separate MOQ optimization is performed for each group. The implicit default value is a constant across all items.`GroupId`

: identifies the grouping for the MOQ constraints.`GroupQuantity`

: the contribution of the grid line to the MOQ constraint.`GroupMinQuantity`

: the lower bound of the MOQ constraint.

`GroupId`

, `GroupQuantity`

and `GroupMinQuantity`

, the last three parameters, can have multiple arguments, one for each distinct MOQ constraint; however the same number of arguments should be provided for each parameter. Up to 8 arguments can be passed to the MOQ solvers, representing as many distinct MOQ constraints.

## Minimal Order Quantities (MOQ) per SKU

Suppliers frequently impose minimal order quantities (MOQs) on their customers. Such MOQ constraints can be applied at various levels: per SKU, per category, per order, etc. Let’s assume that we are facing MOQ constraints at the SKU level: for every SKU there is a minimal quantity to be ordered, and beyond this threshold, it is up to the person ordering the goods to decide if any additional units of that specific SKU need to be ordered or not. In the script below, we assume that the *items* file contains a `MOQ`

column. If there is no MOQ constraint applicable, then this field is expected to be equal to 1.

```
read "/sample/Lokad_Items.tsv"
read "/sample/Lokad_Orders.tsv" as Orders
read "/sample/Lokad_PurchaseOrders.tsv" as PO
// Filtering on closed POs
where PO.DeliveryDate > PO.Date
Horizon = forecast.leadtime(
hierarchy: Category, SubCategory
present: (max(Orders.Date) by 1) + 1
leadtimeDate: PO.Date
leadtimeValue: PO.DeliveryDate - PO.Date + 1)
Demand = forecast.demand(
horizon: Horizon
hierarchy: Category, SubCategory
present: (max(Orders.Date) by 1) + 1
demandDate: Orders.Date
demandValue: Orders.Quantity)
budget := 1000
MinOrderQuantity = 5
// % relative to selling price
oosPenalty := 0.25
// % annual carrying cost relative to purchase price
carryingCost := 0.3
// % annual economic discount
discount := 0.20
M = SellPrice - BuyPrice
S = - 0.25 * SellPrice // stock-out penalty
// % '0.3' as annual carrying cost
C = - 0.3 * BuyPrice * mean(Leadtime) / 365
// back-order case
MB = 0.5 * SellPrice
MBU = MB * uniform(1, Backorder)
// back-order case
SB = 0.5 * SellPrice
SBU = SB * uniform(1, Backorder)
// opportunity to buy later
AM = 0.3
// % '0.2' as annual economic discount
AC = 1 - 0.2 * mean(Leadtime) / 365
RM = MBU + (stockrwd.m(Demand, AM) * M) >> Backorder
RS = SBU + zoz(stockrwd.s(Demand) * S) >> Backorder
RC = (stockrwd.c(Demand, AC) * C) >> BackOrder
R = RM + RS + RC // plain recomposition
Stock = StockOnHand + StockOnOrder
DBO = Demand >> BackOrder
table G = extend.distrib(DBO, Stock)
G.Q = G.Max - G.Min + 1
G.Reward = int(R, G.Min, G.Max)
G.Cost = BuyPrice * G.Q
where G.Max >= Stock
G.Eligible = solve.moq(
Item: Id
Quantity: G.Min
Reward: G.Reward
Cost: G.Cost
MaxCost: budget
GroupId: Id
GroupQuantity: G.Q
GroupMinQuantity: MinOrderQuantity)
where G.Eligible & sum(G.Eligible ? 1 : 0) > 0
show table "Purchase priority list (budget: $\{budget})" a1f4 tomato with
Id as "Id"
MinOrderQuantity as "MOQ"
sum(G.Q) as "Quantity"
sum(G.Reward) as "Reward" unit: "$"
sum(BuyPrice * G.Q) as "Purchase Cost" unit: "$"
group by Id
order by [sum(G.Reward) / sum(G.Cost)] desc
```

This script produces a dashboard where the MOQ constraints are satisfied for all the lines in the list. In order to satisfy those constraints, we use the `moqsolv`

special function of Envision. In the present case, we only have 1 type of MOQ constraint, but the function `moqsolv`

also works with multiple MOQ constraints. The function `moqsolv`

returns `true`

for the lines of the grid that are elected to be part of the final result. Under the hood, `moqsolv`

is using an advance non-linear optimizer that has been specifically tailored for the MOQ problem.

## Minimal Order Quantities (MOQ) per groups of SKUs

We have seen in the previous section how to handle MOQ constraints at the SKU level. Now, let’s review how such a constraint can be handled at a higher level of aggregation. Let’s assume that the MOQ threshold is made available as part of the *items* file. Since the MOQ constraint applies at a certain grouping level, we assume, for the sake of consistency, that all items that belong to the same MOQ group have the same MOQ value. The script below illustrates how this multi-SKU constraint can be handled.

```
read "/sample/Lokad_Items.tsv"
read "/sample/Lokad_Orders.tsv" as Orders
read "/sample/Lokad_PurchaseOrders.tsv" as PO
// snipped ..
G.Eligible = solve.moq(
Item: Id
Quantity: G.Min
Reward: G.Reward
Cost: G.Cost
MaxCost: budget
GroupId: SubCategory
GroupQuantity: G.Q
// MOQ per subcategory
GroupMinQuantity: MinOrderQuantity)
// snipped ...
```

The script above is actually near identical to the script of the previous section. For the sake of clarity, we are only displaying the call to `moqsolv`

, as it’s the only line that changes, as it takes an alternative MOQ constraint as argument.

## Lot multipliers per SKU

Sometimes, SKUs can only be ordered by certain quantities, and unlike the minimal order quantity (MOQ) constraint detailed above, the ordered quantity needs to be a multiple of a “base” quantity. For example, a product can only be ordered by crates of 12 units. It’s not possible to order 13 units, it’s either 12 units or 24 units. We refer to the quantity to be multiplied as the *lot multiplier*. It is possible to adjust the prioritization logic to fit this constraint as well.

```
read "/sample/Lokad_Items.tsv"
read "/sample/Lokad_Orders.tsv" as Orders
read "/sample/Lokad_PurchaseOrders.tsv" as PO
LotMultiplier = 5
// Filtering on closed POs
where PO.DeliveryDate > PO.Date
Horizon = forecast.leadtime(
hierarchy: Category, SubCategory
present: (max(Orders.Date) by 1) + 1
leadtimeDate: PO.Date
leadtimeValue: PO.DeliveryDate - PO.Date + 1)
Demand = forecast.demand(
horizon: Horizon
hierarchy: Category, SubCategory
present: (max(Orders.Date) by 1) + 1
demandDate: Orders.Date
demandValue: Orders.Quantity)
show form "Purchase with lot multipliers" a1b2 tomato with
Form.budget as "Max budget"
budget := Form.budget
// % relative to selling price
oosPenalty := 0.25
// % annual carrying cost relative to purchase price
carryingCost := 0.3
// % annual economic discount
discount := 0.20
M = SellPrice - BuyPrice
// stock-out penalty
S = - 0.25 * SellPrice
// % '0.3' as annual carrying cost
C = - 0.3 * BuyPrice * mean(Leadtime) / 365
// back-order case
MB = 0.5 * SellPrice
MBU = MB * uniform(1, Backorder)
// back-order case
SB = 0.5 * SellPrice
SBU = SB * uniform(1, Backorder)
// opportunity to buy later
AM = 0.3
// % '0.2' as annual economic discount
AC = 1 - 0.2 * mean(Leadtime) / 365
RM = MBU + (stockrwd.m(Demand, AM) * M) >> Backorder
RS = SBU + zoz(stockrwd.s(Demand) * S) >> Backorder
RC = (stockrwd.c(Demand, AC) * C) >> BackOrder
R = RM + RS + RC // plain recomposition
Stock = StockOnHand + StockOnOrder
DBO = Demand >> BackOrder
// the third argument is 'LotMultiplier'
table G = extend.distrib(DBO, Stock, LotMultiplier)
G.Q = G.Max - G.Min + 1
G.Reward = int(R, G.Min, G.Max)
G.Cost = BuyPrice * G.Q
where G.Max > Stock
// a mock MOQ constraint
G.Eligible = solve.moq(
Item: Id
Quantity: G.Min
Reward: G.Reward
Cost: G.Cost
MaxCost: budget
GroupId: Id
GroupQuantity: G.Q
GroupMinQuantity: 1)
where G.Eligible & sum(G.Eligible ? 1 : 0) > 0
show table "Purchase priority list (budget: $\{budget})" a1f4 tomato with
Id as "Id"
sum(G.Q) as "Quantity"
sum(G.Reward) as "Reward" unit: "$"
sum(BuyPrice * G.Q) as "Purchase Cost" unit: "$"
group by Id
order by [sum(G.Reward) / sum(G.Cost)] desc
```

The script leverages a special behavior of the `extend.distrib()`

function, which is precisely intended to capture lot multiplier constraints. The third argument of this function is the lot multiplier quantity.

## Target container capacity per supplier

With overseas imports frequently comes the constraint of purchasing up to a full container, or half of a full container. The volume of the container is known, and in this example we assume that the volumes of all the items being purchased are known too. The goal is to compose a short list of items that represents the contents of the next full container to be purchased.

In order to make things a tiny bit more complex, let’s assume that there is *no shipping consolidation between suppliers*. Thus, in order to compose a container, all the purchase lines should be associated to the same supplier. This implies that one should first identify the most pressing supplier, and then, fill the container accordingly. Let’s suppose that the *items* file contains a `S`

(for *Supplier*) column indicating the primary vendor, assuming we are in a mono-sourcing scenario (i.e. each item has exactly one supplier).

```
read "/sample/Lokad_Items.tsv"
read "/sample/Lokad_Orders.tsv" as Orders
read "/sample/Lokad_PurchaseOrders.tsv" as PO
// Filtering on closed POs
where PO.DeliveryDate > PO.Date
Horizon = forecast.leadtime(
hierarchy: Category, SubCategory
present: (max(Orders.Date) by 1) + 1
leadtimeDate: PO.Date
leadtimeValue: PO.DeliveryDate - PO.Date + 1)
Demand = forecast.demand(
horizon: Horizon
hierarchy: Category, SubCategory
present: (max(Orders.Date) by 1) + 1
demandDate: Orders.Date
demandValue: Orders.Quantity)
// expected volume of the container (m3)
cV := 15
// expected jumping threshold of the container
cJT := 2 * cV
// % relative to selling price
oosPenalty := 0.25
// % annual carrying cost relative to purchase price
carryingCost := 0.3
// % annual economic discount
discount := 0.20
M = SellPrice - BuyPrice
// stock-out penalty
S = - 0.25 * SellPrice
// % '0.3' as annual carrying cost
C = - 0.3 * BuyPrice * mean(Leadtime) / 365
// back-order case
MB = 0.5 * SellPrice
MBU = MB * uniform(1, Backorder)
// back-order case
SB = 0.5 * SellPrice
SBU = SB * uniform(1, Backorder)
// opportunity to buy later
AM = 0.3
// % '0.2' as annual economic discount
AC = 1 - 0.2 * mean(Leadtime) / 365
RM = MBU + (stockrwd.m(Demand, AM) * M) >> Backorder
RS = SBU + zoz(stockrwd.s(Demand) * S) >> Backorder
RC = (stockrwd.c(Demand, AC) * C) >> BackOrder
R = RM + RS + RC // plain recomposition
Stock = StockOnHand + StockOnOrder
DBO = Demand >> BackOrder
table G = extend.distrib(DBO, Stock)
G.Q = G.Max - G.Min + 1
G.Rwd = int(R, G.Min, G.Max) // reward
G.Score = G.Rwd / max(1, BuyPrice * G.Q)
G.V = Volume * G.Q
where G.Max > Stock
G.Rk = rank(G.Score, Id, -G.Max)
// 'S' is for Supplier
G.CId = priopack(G.V, cV, cJT, Id) by S sort G.Rk
// filling the container for the most pressing
// supplier
where sum(G.Q) > 0
show table "Containers \{cV}m3" a1f4 tomato with
same(Supplier) as "Supplier"
G.CId as "Container"
Id as "Id"
sum(G.Q) as "Quantity"
sum(G.Rwd) as "Reward" unit:"$"
sum(BuyPrice * G.Q) as "Investment" unit:"$"
sum(G.V) as "Volume{ m3}"
group by [G.CId, Id]
order by [avg(sum(G.Rwd) by [S, G.CId])] desc
```

This dashboard produces a single table that details the list of batches sorted by decreasing reward. The stock rewards are computed based on the `stockrwd`

function. The batching logic - aka splitting quantities over several containers - is performed using the `priopack`

function. This function has been specifically introduced to Envision for the purpose of dealing with the per-container constraints.