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.