Заказы на закупку с ограничениями — система оптимизации товарных запасов

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
  // Укажите один вариант из трех: 
  MaxCost: maxBudget
  MaxTarget: maxTarget
  MinTarget: minTarget
  // Необязательно:
  Target: G.Target 
  // Необязательно, но должно быть одно число для всех, максимум 8
  GroupId:          A,      B
  GroupQuantity:    G.A,    G.B
  GroupMinQuantity: AMoq,   BMoq)

Используются следующие параметры:

  • G: схема — таблица, обычно полученная с помощью оператора extend.distrib().
  • Item: коды SKU или товаров, для которых проводится оптимизация MOQ.
  • Quantity: размер схемы, используется для указания количества строк в таблице.
  • Reward: экономическая выгода от покупки товаров в указанной строке схемы.
  • Cost: финансовые расходы, связанные с покупкой товаров в указанной строке схемы.
  • MaxCost: пороговое значение одного из трех режимов оптимизации для алгоритма. Значение MaxCost показывает, что строки таблицы будут добавляться, пока не закончатся выделенные средства.
  • MaxTarget: то же самое. При использовании данного оператора цель достигается снизу — количество строк не может превышать целевой показатель.
  • MinTarget: то же самое. При использовании данного оператора цель достигается сверху — количество строк не может быть меньше целевого показателя.
  • Target: требуемая доля строки. Используется только с MaxTarget или MinTarget.
  • GroupId: указывает на группировку ограничений МOQ.
  • GroupQuantity: доля строки от ограничения MOQ.
  • GroupMinQuantity: нижняя граница ограничения MOQ.

Три параметра GroupId, GroupQuantity и GroupMinQuantity могут иметь по нескольку аргументов, по одному для каждого отдельно взятого ограничения MOQ, однако для всех параметров должно быть указано одинаковое количество аргументов. В алгоритме расчета MOQ можно использовать до 8 аргументов, отражающих соответствующее число ограничений MOQ.

Минимальный объем (MOQ) для SKU

Поставщики часто устанавливают минимальный объем заказа (MOQ) для своих клиентов. Ограничения по MOQ могут применяться на разных уровнях: на уровне SKU, на уровне категории товаров, на уровне заказа и т. д. Предположим, что мы столкнулись с ограничением по MOQ на уровне SKU, то есть для каждой SKU существует минимальное количество единиц, которое можно заказать, после чего клиенты вправе самостоятельно решать, нужно ли им заказать еще несколько единиц той же SKU или нет. В сценарии ниже мы исходим из предположения, что в файле items есть столбец 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 //фильтрование по закрытым заказам на покупку
  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 // % относительно цены продажи
carryingCost := 0.3  // % ежегодных расходов на содержание товара относительно цены закупки
discount := 0.20 // % ежегодная скидка

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 соблюдаются для всех позиций списка. Чтобы учесть все необходимые ограничения, используется функция Envision moqsolv. В данном случае присутствует только 1 ограничение по MOQ, однако функция moqsolv позволяет обрабатывать и несколько ограничений MOQ. Функция moqsolv возвращает значение true для всех строк таблицы, которые должны быть показаны в окончательном варианте. В основе функции moqsolv лежит мощный нелинейный оптимизатор, ориентированный на работу с MOQ.

Минимальный объем (MOQ) для групп SKU

В предыдущем разделе мы узнали, как работать с ограничениями MOQ на уровне SKU. Теперь давайте рассмотрим, как такие ограничения работают на более высоком уровне объединения данных. Предположим, что пороговое значение MOQ включено в файл items. Если ограничение MOQ применяется на определенном уровне группировки, мы будем считать, что все предметы, принадлежащие к одной группе MOQ имеют одно и то же значение MOQ. Сценарий, приведенный ниже показывает, как можно обрабатывать подобное ограничение со множеством SKU.

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


// опущено

   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 по подкатегориям

  // опущено

Сценарий, показанный выше, практически не отличается от того, который мы рассматривали в предыдущем разделе. Для простоты мы показали только вызов функции 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 //фильтрование по закрытым заказам на покупку
  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 // % относительно цены продажи
carryingCost := 0.3  // % ежегодных расходов на содержание товара относительно цены закупки
discount := 0.20 // % ежегодная скидка

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(), которая предназначена для работы с ограничениями по коэффициенту заказа. Третий аргумент этой функции как раз представляет собой значение коэффициента заказа.

Объем контейнера и поставщики

При импорте товаров через морские порты перед компаниями часто встает необходимость наполнения контейнера полностью или хотя бы наполовину. Объем контейнера известен, и в данном примере предположим, что объемы всех приобретаемых товаров также известны. Наша цель — составить список наименований, который показывал бы, как при размещении очередного заказа на закупку можно максимально заполнить контейнер.

Давайте немного усложним ситуацию и предположим, что товары, заказанные у разных поставщиков, невозможно поместить в один контейнер. Таким образом, заполнять контейнер можно только товарами, привязанными к одному поставщику. Тогда сначала необходимо определить поставщика, в товарах которого вы нуждаетесь больше всего, а затем заполнить контейнер соответствующим образом. Предположим, что в файле items есть столбец 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 //фильтрование по закрытым заказам на покупку
  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 // ожидаемый объем контейнера (м3)

oosPenalty := 0.25 // % относительно цены продажи
carryingCost := 0.3  // % ежегодных расходов на содержание товара относительно цены закупки
discount := 0.20 // % ежегодная скидка

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)

  // заполнение контейнера для самого актуального поставщика
  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 специально для работы с заказами, для которых действуют ограничения по объему контейнера.