Quantitativi minimi e altri vincoli di approvvigionamento


Home » Risorse » Qui

Con le previsioni probabilistiche è possibile generare una lista di priorità degli acquisti, in cui ogni unità supplementare eventualmente da acquistare viene classificata sulla base di business driver, come il margine lordo previsto e i costi di mantenimento a magazzino previsti. Tuttavia, i quantitativi minimi di ordine (o, con acronimo inglese, MOQ) introducono dei vincoli non lineari che complicano il calcolo delle quantità da acquistare. Per gestire questo tipo di situazione, molto frequente in logistica, Lokad ha messo a punto un solutore numerico apposito per i MOQ, utile anche nel caso in cui ai quantitativi minimi si aggiunga anche il vincolo delle spedizioni in container.


La funzione di chiamata solve.moq

Il solutore MOQ è un solutore numerico specializzato, a cui si può accedere, all'interno di Envision, come funzione di chiamata. Il quadro teorico di riferimento è il problema MOQ generale, che rappresenta un problema di programmazione intera. La sintassi per la funzione è illustrata qui di seguito:

G.Buy = solve.moq(
  Item: Id 
  Quantity: G.Min
  Reward: G.Reward 
  Cost: G.Cost
  // Obbligatorio uno tra: 
  MaxCost: maxBudget
  MaxTarget: maxTarget
  MinTarget: minTarget
  // Facoltativo:
  Target: G.Target 
  TargetGroup: Supplier
  // Facoltativo, ma deve avere 
  // lo stesso numero per ognuno, massimo 8
  GroupId:          A,      B
  GroupQuantity:    G.A,    G.B
  GroupMinQuantity: AMoq,   BMoq)

I parametri sono i seguenti:

  • G: è la griglia, una tabella ottenuta di solito attraverso extend.distrib();
  • Item (articolo): identificativi della SKU o dei prodotti rilevanti dal punto di vista dell'ottimizzazione dei MOQ;
  • Quantity (quantità): quantità della griglia, utilizzata per ordinare le righe della griglia;
  • Reward (rendimento): ritorno economico associato all'acquisto della riga della griglia;
  • Cost (costo): costo economico associato all'acquisto della riga della griglia;
  • MaxCost (costo massimo): soglia per una delle tre modalità di ottimizzazione da scegliere per il solutore. MaxCost indica che le righe della griglia sono considerate fino a che il budget non viene raggiunto e che nessuna riga può essere aggiunta senza superare il budget;
  • MaxTarget (obiettivo massimo): soglia per una delle tre modalità di ottimizzazione da scegliere per il solutore. Se usata, l'obiettivo viene raggiunto partendo dal basso; nessuna altra riga potrà essere aggiunta senza superare l'obiettivo;
  • MinTarget (obiettivo minimo): soglia per una delle tre modalità di ottimizzazione da scegliere per il solutore. Se usata, l'obiettivo viene raggiunto partendo dall'alto; nessuna altra riga potrà essere aggiunta senza restare al di sotto dell'obiettivo;
  • Target (obiettivo): contributo all'obiettivo associato alla riga della griglia. Da applicare solo se sono già specificati MaxTarget o MinTarget;
  • TargetGroup: se specificato, per ogni gruppo viene eseguita un'ottimizzazione MOQ distinta. Il valore predefinito implicito è una costante per tutti gli articoli;
  • GroupId (id gruppo): identifica il raggruppamento valido per il vincolo MOQ;
  • GroupQuantity (quantità gruppo): contributo della riga della griglia al vincolo MOQ;
  • GroupMinQuantity (quantità minima gruppo): limite inferiore del vincolo MOQ.

Gli ultimi tre parametri, GroupId, GroupQuantity e GroupMinQuantity, possono avere più argomenti, uno per ogni diverso vincolo MOQ; da notare, però, che ogni parametro dovrà avere lo stesso numero di argomenti. I solutori MOQ possono gestire fino a 8 argomenti, uno per vincolo MOQ.

Quantitativi minimi di ordine (MOQ) per SKU

Molti fornitori impongono ai propri clienti dei quantitativi minimi (detti anche MOQ, dall'inglese Minimal Order Quantities) da rispettare per ogni ordine. Si tratta di un vincolo che può essere applicato a vari livelli: a livello di SKU, di categoria, di ordine, etc. Supponiamo di avere un quantitativo minimo ordinabile a livello di SKU: per ogni SKU, avremo una quantità minima ordinabile e, oltre questa soglia, dovremo decidere se varrà la pena ordinare almeno una unità supplementare per ogni specifica SKU. Nello script che vedremo più avanti, ipotizziamo che il file contenente gli articoli contenga una colonna MOQ. Se non abbiamo un simile vincolo, allora questo campo sarà uguale a 1.

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

// Filtro per PO archiviati
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

// % relativa al prezzo di vendita
oosPenalty := 0.25
// % di costi annui di mantenimento a magazzino relativa al prezzo di acquisto
carryingCost := 0.3
// % di sconto economico annuale
discount := 0.20

M = SellPrice - BuyPrice
S = - 0.25 * SellPrice // svantaggio per rottura di stock
// % dello 0,3 come costo annuo di mantenimento a magazzino
C = - 0.3 * BuyPrice * mean(Leadtime) / 365
// in caso di ordini arretrati
MB = 0.5 * SellPrice 
MBU = MB * uniform(1, Backorder)
// in caso di ordini arretrati
SB = 0.5 * SellPrice 
SBU = SB * uniform(1, Backorder)
// opportunità di acquistare più avanti
AM = 0.3
// % dello 0,2 come sconto economico annuale
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 // ricomposizione semplice

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

Lo script produce un pannello di controllo in cui il vincolo del quantitativo minimo d'ordine viene rispettato per ogni articolo. Per soddisfare questo vincolo, usiamo la speciale funzione di Envision moqsolv. In questo caso particolare, abbiamo un solo vincolo di questo tipo, ma la funzione è in grado di gestire anche più vincoli legati al quantitativo minimo d'ordine. La funzione moqsolv restituisce true (vero) per le righe della griglia che possono concorrere al risultato finale. Per fare ciò, moqsolv utilizza un ottimizzatore non lineare avanzato, pensato proprio per gestire il problema MOQ.

Quantitativi minimi per gruppi di SKU

Abbiamo visto nella sezione precedente come gestire i vincoli legati ai quantitativi minimi a livello di SKU. Vediamo ora come gestire questi vincoli a un livello di aggregazione superiore. Poniamo che il quantitativo minimo sia disponibile nel file articoli. Poiché il quantitativo minimo riguarda un gruppo preciso di articoli, ipotizziamo che tutti gli articoli appartenenti allo stesso gruppo abbiano lo stesso quantitativo minimo. Nello script qui sotto vediamo come può essere gestito questo vincolo per più 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
		// MOQ per sottocategoria
    GroupMinQuantity: MinOrderQuantity) 

  // ...

Lo script qui sopra è quasi identico a quello che abbiamo visto nella sezione precedente. Per maggiore chiarezza, riportiamo solo la chiamata della funzione moqsolv: questa è l'unica riga che cambia, poiché contiene come argomento un vincolo MOQ alternativo.

Moltiplicatore di partite per SKU

A volte è possibile ordinare le SKU solo in precise quantità e, a differenza di quanto avviene con i quantitativi minimi di ordine, le quantità di ordine devono essere multipli di una quantità di "base". Ad esempio, se un prodotto può essere ordinato solo per casse da 12 pezzi, non possiamo ordinare 13 pezzi, ma dobbiamo ordinarne o 12 o 24. La quantità da moltiplicare è detta moltiplicatore di partite. Possiamo quindi regolare la logica di priorità tenendo conto di questo vincolo.

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

LotMultiplier = 5

// Filtro per PO archiviati
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

// % relativa al prezzo di vendita
oosPenalty := 0.25 
// % di costi annui di mantenimento a magazzino relativa al prezzo di acquisto
carryingCost := 0.3  
// % di sconto economico annuale
discount := 0.20

M = SellPrice - BuyPrice
// svantaggio per rottura di stock
S = - 0.25 * SellPrice
// % dello 0,3 come costi annui di mantenimento a magazzino
C = - 0.3 * BuyPrice * mean(Leadtime) / 365
// in caso di ordini arretrati
MB = 0.5 * SellPrice
MBU = MB * uniform(1, Backorder)
// in caso di ordini arretrati
SB = 0.5 * SellPrice
SBU = SB * uniform(1, Backorder)
// opportunità di acquistare più avanti
AM = 0.3
// % dello 0,2 come sconto economico annuale
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 // ricomposizione semplice

Stock = StockOnHand + StockOnOrder

DBO = Demand >> BackOrder

// il terzo argomento è "LotMultiplier" (moltiplicatore di partite)
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
  // vincolo MOQ fittizio
  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

Lo script sfrutta un particolare comportamento della funzione extend.distrib(), che mira proprio a "catturare" i vincoli relativi al moltiplicatore di partite. Il terzo argomento della funzione, infatti, è proprio la quantità del moltiplicatore di partite.

Capacità container mirata per fornitore

Con le importazioni dall'estero, il problema di dover acquistare un container intero o metà container è ormai piuttosto frequente. Il volume del container è noto e, nell'esempio qui di seguito, diamo per scontato che lo sia anche il volume delle merci da acquistare. L'obiettivo è quello di stilare un breve elenco di articoli che rappresentino il contenuto del prossimo container pieno da acquistare.

Per complicare un po' le cose, ipotizziamo che non sia possibile raggruppare gli ordini inviati a più fornitori. Quindi, per riempire un container, tutte le righe degli acquisti dovranno essere associate allo stesso fornitore. Ciò significa che dobbiamo prima di tutto identificare il fornitore da cui è più urgente acquistare e, in seguito, riempire il container di conseguenza. Supponiamo che il file Articoli contenga una colonna S (fornitore), che indichi il venditore principale, e che a ogni articolo corrisponda esattamente un fornitore.

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

// Filtro per PO archiviati
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)

// volume atteso del container (in m3)
cV := 15
// soglia di salto attesa per il container
cJT := 2 * cV

// % relativa al prezzo di vendita
oosPenalty := 0.25
// % dei costi annui di mantenimento a magazzino relativa al prezzo di acquisto
carryingCost := 0.3
// % di sconto economico annuale
discount := 0.20

M = SellPrice - BuyPrice
// svantaggio per rottura di stock
S = - 0.25 * SellPrice
// % dello 0,3 come costo annuo di mantenimento a magazzino
C = - 0.3 * BuyPrice * mean(Leadtime) / 365
// in caso di ordini arretrati
MB = 0.5 * SellPrice
MBU = MB * uniform(1, Backorder)
// in caso di ordini arretrati
SB = 0.5 * SellPrice
SBU = SB * uniform(1, Backorder)
// opportunità di acquistare più avanti
AM = 0.3
// % dello 0,2 come sconto economico annuale
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 // ricomposizione semplice

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) // ricompensa
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" sta per fornitore (dall'inglese "supplier")
  G.CId = priopack(G.V, cV, cJT, Id) by S sort G.Rk

  // riempire il container per il
  // fornitore più urgente
  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

Questo pannello di controllo produce una sola tabella, che contiene un elenco dettagliato dei gruppi ordinati per rendimento decrescente. Il rendimento delle scorte è calcolato sulla base della funzione stockrwd. La logica di raggruppamento (ossia quella utilizzata per suddividere le quantità tra vari container) è la funzione priopack, che è stata introdotta in Envision proprio per le situazioni in cui è necessario suddividere gli acquisti in container.