MOQ 和其他订货限制


首页 » 资源 » 此处

利用概率预测,可以生成每个要采购的增量单元依据预期毛利润和预期存货持有成本等商业推动因素进行排列的采购优先级列表。然而,MOQ(最小订单量)引入了非线性限制,使得采购订单量的计算复杂化。为了应对供应链中经常遇到的这项要求,Lokad 设计了一种专门用于 MOQ 的数值解算器。这种解算器还解决了 MOQ 与集装箱限制相结合的情况。


solve.moq 调用函数

MOQ 解算器是一种专门的数值解算器,它可以在 Envision 中作为一种调用函数来访问。其数学参考框架为表示整数编程问题的一般 MOQ 问题。此函数的语法说明如下。

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 
// Optional, but must have the same number for each, max 8
GroupId: A, B
GroupQuantity: G.A, G.B
GroupMinQuantity: AMoq, BMoq)

参数如下:

  • G网格,是一种一般通过 extend.distrib() 获取的表。
  • Item:与 MOQ 优化相关的 SKU 或产品的标识符。
  • Quantity:网格数量,用于网格线订购。
  • Reward:与采购此行网格相关联的经济效益。
  • Cost:采购此行网格的经济成本。
  • MaxCost:解算器的三种优化模式之一的阈值。MaxCost 表示在预算用完之前将采纳网格线,并且在不超过预算时不能再添加网格线。
  • MaxTarget:同上。使用时,目标自下而上;在不超出目标的情况下,不能再添加网格线。
  • MinTarget:同上。使用时,目标自上而下;在不低于目标的情况下,不能再添加网格线。
  • Target:与网格线相关联的目标贡献。仅当指定 MaxTargetMinTarget 时才会应用。
  • GroupId:标识 MOQ 限制的分组。
  • GroupQuantity:网格线对 MOQ 限制的贡献。
  • GroupMinQuantity:MOQ 的下限。

GroupIdGroupQuantityGroupMinQuantity 这三个参数可以有多个变元,每个不同的 MOQ 限制各一个;但每个参数应当提供相同数量的变元。最多可以将 8 个变元传递给 MOQ 解算器,这些变元代表许多不同的 MOQ 限制。

每个 SKU 的最小订单量 (MOQ)

供应商经常向客户施加最小订单量 (MOQ) 限制。此类 MOQ 限制可在不同级别应用:SKU、目标、订单等等。假定 SKU 级别存在 MOQ 限制:每个 SKU 存在最小订单量,除此阈值外,负责订购货品的人还可以决定是否针对该 SKU 进行额外订货。在下面的脚本中,假定项目文件包含 MOQ 列。如果没有适用的 MOQ 限制,则这个字段应为 1。

read "/sample/Lokad_Items.tsv"
read "/sample/Lokad_Orders.tsv" as Orders
read "/sample/Lokad_PurchaseOrders.tsv" as PO

where PO.DeliveryDate > PO.Date //Filtering on closed POs
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

oosPenalty := 0.25 // % relative to selling price
carryingCost := 0.3 // % annual carrying cost relative to purchase price
discount := 0.20 // % annual economic discount

M = SellPrice - BuyPrice
S = - 0.25 * SellPrice // stock-out penalty
C = - 0.3 * BuyPrice * mean(Leadtime) / 365// % '0.3' as annual carrying cost
MB = 0.5 * SellPrice // back-order case
SB = 0.5 * SellPrice // back-order case
AM = 0.3 // opportunity to buy later
AC = 1 - 0.2 * mean(Leadtime) / 365// % '0.2' as annual economic discount

RM = MB * uniform(1, Backorder) + (stockrwd.m(Demand, AM) * M) >> Backorder
RS = SB * uniform(1, Backorder) + zoz(stockrwd.s(Demand) * S) >> Backorder
RC = (stockrwd.c(Demand, AC) * C) >> BackOrder
R = RM + RS + RC // plain recomposition

table G = extend.distrib(Demand >> BackOrder, StockOnHand + StockOnOrder)
G.Q = G.Max - G.Min + 1
G.Reward = int(R, G.Min, G.Max)
G.Cost = BuyPrice * G.Q

where G.Max >= StockOnHand + StockOnOrder
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{$}"
sum(BuyPrice * G.Q) as "Purchase Cost{$}"
group by Id
order by sum(G.Reward) / sum(G.Cost) desc

这段脚本将生成列表中的所有行均满足 MOQ 限制的仪表板。为了满足这些限制,我们使用了 moqsolv 这个特殊的 Envision 函数。尽管在示例中只有一种类型的 MOQ 限制,但函数 moqsolv 也可处理多种 MOQ 限制。对于网格中被选定为最终结果的行,函数 moqsolv 将返回 true。从本质上说,moqsolv 使用了专门为解决 MOQ 问题而量身定制的高级非线性优化器。

每组 SKU 的最小订单量 (MOQ)

在上一节中我们了解了如何处理 SKU 级别的 MOQ 限制,接下来将了解如何在更高聚合级别处理此类限制。假设 items 文件中提供了 MOQ 阈值。鉴于 MOQ 限制要应用于特定群组级别,为了一致起见,我们假设所有项目属于具有相同 MOQ 值的相同 MOQ 组。下面这段脚本说明了这种多 SKU 限制的处理方法。

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
GroupMinQuantity: MinOrderQuantity) // MOQ per subcategory

// snipped ...

上面这段脚本其实与上一节中的脚本大致相同。为了清楚起见,我们只显示对 moqsolv 的调用,因为只有这一行是不同的,这一行使用了一种替代性的 MOQ 限制来作为参数。

每个 SKU 的批次倍数

有时 SKU 只能按特定的数量订购,与上文详述的最小订单量 (MOQ) 限制不同,我们需要将订货数量乘以某个"基数"。举个例子,某种产品只能按每箱 12 件订购。不能订购 13 件,只能订购 12 件或 24 件。我们将所要乘的数量称之为批次倍数。您也可以调整这种优先化逻辑,以适合这一限制。

read "/sample/Lokad_Items.tsv"
read "/sample/Lokad_Orders.tsv" as Orders
read "/sample/Lokad_PurchaseOrders.tsv" as PO

LotMultiplier = 5

where PO.DeliveryDate > PO.Date //Filtering on closed POs
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

oosPenalty := 0.25 // % relative to selling price
carryingCost := 0.3 // % annual carrying cost relative to purchase price
discount := 0.20 // % annual economic discount

M = SellPrice - BuyPrice
S = - 0.25 * SellPrice // stock-out penalty
C = - 0.3 * BuyPrice * mean(Leadtime) / 365// % '0.3' as annual carrying cost
MB = 0.5 * SellPrice // back-order case
SB = 0.5 * SellPrice // back-order case
AM = 0.3 // opportunity to buy later
AC = 1 - 0.2 * mean(Leadtime) / 365// % '0.2' as annual economic discount

RM = MB * uniform(1, Backorder) + (stockrwd.m(Demand, AM) * M) >> Backorder
RS = SB * uniform(1, Backorder) + zoz(stockrwd.s(Demand) * S) >> Backorder
RC = (stockrwd.c(Demand AC) * C) >> BackOrder
R = RM + RS + RC // plain recomposition

// the third argument is 'LotMultiplier'
table G = extend.distrib(Demand >> BackOrder, StockOnHand + StockOnOrder, LotMultiplier)
G.Q = G.Max - G.Min + 1
G.Reward = int(R, G.Min, G.Max)
G.Cost = BuyPrice * G.Q

where G.Max > StockOnHand + StockOnOrder
// 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{$}"
sum(BuyPrice * G.Q) as "Purchase Cost{$}"
group by Id
order by sum(G.Reward) / sum(G.Cost) desc

这段脚本利用了 extend.distrib() 函数的一种特殊行为,即准确捕获批次倍数限制。此函数的第三个变元即为批次倍数的数量。

每个供应商的目标集装箱容量

海外进口常常存在购满整个集装箱或半箱的限制。集装箱的体积已知,在本例中,假设所购的所有物品的体积也已知。我们的目标是制定出一个简短项目清单,此清单展示了要购满下一个集装箱的内容物。

为了让事情变得更复杂点,我们假设供应商之间不会合并发货。因此,为了装满一个集装箱,所有采购行应关联同一个供应商。这意味着首先要识别最紧迫的供应商,然后相应装入集装箱。假设项目文件包含一个叫做 Supplier 的列用于指示主要供应商,同时还要假设单一采购场景(即每个货品只有一个供应商)。

read "/sample/Lokad_Items.tsv"
read "/sample/Lokad_Orders.tsv" as Orders
read "/sample/Lokad_PurchaseOrders.tsv" as PO

where PO.DeliveryDate > PO.Date //Filtering on closed POs
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)

cVolume := 15 // expected volume of the container (m3)

oosPenalty := 0.25 // % relative to selling price
carryingCost := 0.3 // % annual carrying cost relative to purchase price
discount := 0.20 // % annual economic discount

M = SellPrice - BuyPrice
S = - 0.25 * SellPrice // stock-out penalty
C = - 0.3 * BuyPrice * mean(Leadtime) / 365 // % '0.3' as annual carrying cost
MB = 0.5 * SellPrice // back-order case
SB = 0.5 * SellPrice // back-order case
AM = 0.3 // opportunity to buy later
AC = 1 - 0.2 * mean(Leadtime) / 365 // % '0.2' as annual economic discount

RM = MB * uniform(1, Backorder) + (stockrwd.m(Demand, AM) * M) >> Backorder
RS = SB * uniform(1, Backorder) + zoz(stockrwd.s(Demand) * S) >> Backorder
RC = (stockrwd.c(Demand AC) * C) >> BackOrder
R = RM + RS + RC // plain recomposition

table G = extend.distrib(Demand >> BackOrder, StockOnHand + StockOnOrder)
G.Q = G.Max - G.Min + 1
G.Reward = int(R, G.Min, G.Max)
G.Score = G.Reward / max(1, BuyPrice * G.Q)
G.Volume = Volume * G.Q

where G.Max > StockOnHand + StockOnOrder
G.Rank = rank(G.Score, Id, -G.Max)
G.CId = priopack(G.Rank, G.Volume, Supplier, cVolume, 2 * cVolume, Id)

// filling the container for the most pressing supplier
where sum(G.Q) > 0
show table "Containers (container size: \{cVolume})" a1f4 tomato with
same(Supplier) as "Supplier"
G.CId as "Container"
Id as "Id"
sum(G.Q) as "Quantity"
sum(G.Reward) as "Reward{$}"
sum(BuyPrice * G.Q) as "Investment{$}"
sum(G.Volume) as "Volume{ m3}"
group by (G.CId, Id)
order by avg(sum(G.Reward) by (Supplier, G.CId)) desc

此仪表板将生成单独的一个表,其中包含按效益降序排列的批次列表细节。库存效益根据 stockrwd 函数计算。批处理逻辑 – 即多个集装箱的分割数量 – 则使用 priopack 函数来执行。此函数已在 Envision 中特别介绍过,用于处理存在集装箱限制的采购。