Quantités minimales et autres contraintes sur les commandes


Accueil » Ressources » Ici

Les prévisions probabilistes offrent la possibilité de générer une liste des priorités d'achat dans laquelle chaque unité supplémentaire à acheter est classée en fonction de moteurs d'activité tels que la marge brute prévue et le coût de stock prévu. Les quantités minimales de commande imposent des contraintes non linéaires qui compliquent les calculs des quantités des commandes d’achat. Pour répondre à cette exigence fréquente en logistique, Lokad a conçu un solutionneur numérique spécialement pour les quantités minimales de commande. Le solutionneur fonctionne également lorsque ces dernières sont combinées à des contraintes de taille de container.


La fonction d’appel solve.moq

Le solutionneur de quantités minimales de commande est un solutionneur numérique accessible depuis Envision grâce à une « fonction d’appel ». Le contexte mathématique de référence est le problème général des quantités minimales de commande, un problème de programmation en nombres entiers. La syntaxe de cette fonction est illustrée ci-dessous.

G.Buy = solve.moq(
Item: Id 
Quantity: G.Min
Reward: G.Reward 
Cost: G.Cost
// Fournir l’un des trois paramètres suivants 
MaxCost: maxBudget
MaxTarget: maxTarget
MinTarget: minTarget
// Facultatif
Target: G.Target 
TargetGroup: Supplier
// Facultatif mais doit avoir 
// le même nombre pour chaque, 8 au maximum
GroupId: A, B
GroupQuantity: G.A, G.B
GroupMinQuantity: AMoq, BMoq)

Les paramètres sont les suivants :

  • G : la « grille », table obtenue généralement via « extend.distrib() ».
  • Item : identifiants des SKU ou des produits utiles à l’optimisation des quantités minimales de commande.
  • Quantity : la quantité de la grille, utilisée pour en classer les lignes.
  • Reward : conséquences économiques positives associées à l’achat de la ligne.
  • Cost : conséquences économiques négatives associées à l’achat de la ligne.
  • MaxCost : seuil pour l’un des trois modes d’optimisation du solutionneur. Ce paramètre indique que les lignes de la grille sont prises une à une jusqu’à ce que le budget soit épuisé. Aucune ligne ne peut alors être ajoutée sans dépasser le budget.
  • MaxTarget : idem. Lorsque ce paramètre est utilisé, la cible est une limite maximale et aucune ligne ne peut être ajoutée sans la dépasser.
  • MinTarget : idem. Lorsque ce paramètre est utilisé, la cible est une limite minimale et aucune ligne ne peut être ajoutée sans passer sous cette limite.
  • Target : contribution cible associée à la ligne. Ne s’applique que lorsque ni MaxTarget ni MinTarget ne sont indiqués.
  • TargetGroup: si fourni, une optimisation séparée des quantités minimales de commande est effectuée pour chaque groupe. La valeur par défaut implicite est une constante pour tous les articles.
  • GroupId : identifie le regroupement des contraintes de quantités minimales de commande.
  • GroupQuantity : contribution de la ligne à la contrainte.
  • GroupMinQuantity : limite inférieure des contraintes de quantités minimales de commande.

{GroupId}}, GroupQuantity et GroupMinQuantity, les trois derniers paramètres, peuvent avoir plusieurs arguments, un par contrainte de quantité minimale de commande, mais chaque paramètre doit recevoir le même nombre d’arguments. Les solutionneurs de quantités minimales de commande peuvent recevoir jusqu'à 8 arguments, chacun représentant une contrainte distincte.

Quantité minimale de commande par SKU

Les fournisseurs imposent souvent des quantités minimales de commande à leurs clients. Ces contraintes de quantités minimales peuvent être appliquées à plusieurs niveaux : par SKU, par catégorie, par commande, etc. Supposons que nous ayons affaire à des contraintes de quantités minimales au niveau des SKU : pour chaque SKU, une quantité minimale doit être commandée et, au-delà de ce seuil, la personne qui passe la commande décide si des unités supplémentaires du SKU doivent être commandées ou non. Dans le script ci-dessous, nous faisons l'hypothèse que le fichier « articles » (items) contient une colonne MOQ (quantité minimale de commande). Dans le cas où aucune contrainte de cet ordre n'est applicable, ce champ doit être égal à 1.

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

//Filtrage des commandes d'achat clôturées
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

// % relatif au prix de vente
oosPenalty := 0.25
// % des coûts de stockage annuels relatif au prix de vente
carryingCost := 0.3 
// % de remise économique annuelle
discount := 0.20 

M = SellPrice - BuyPrice
S = - 0.25 * SellPrice // pénalité de rupture de stock
// % '0.3' de coûts de stockage annuels
C = - 0.3 * BuyPrice * mean(Leadtime) / 365 
// cas de commande en souffrance
MB = 0.5 * SellPrice 
MBU = MB * uniform(1, Backorder)
// cas de commande en souffrance
SB = 0.5 * SellPrice 
SBU = SB * uniform(1, Backorder)
// opportunité d'achat ultérieur
AM = 0.3 
// % '0.2' de remise économique annuelle
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 // 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

Ce script produit un tableau de bord dans lequel les contraintes de quantité minimale de commande sont satisfaites pour toutes les lignes de la liste. Pour répondre à ces contraintes, la fonction Envision moqsolv est utilisée. Dans le cas présent, il n'y a qu'un type de contraintes de quantité minimale de commande mais cette fonction peut également en manipuler plusieurs. Elle renvoie true pour les lignes de la grille sélectionnée pour faire partie du résultat final et utilise pour cela un optimiseur non linéaire spécialement conçu pour les problèmes liés aux quantités minimales de commande.

Quantités de commande minimales par groupes d'unités de stockage (SKU)

La section précédente a détaillé comment gérer les contraintes de quantités de commande minimales (MOQ) au niveau d'un SKU. Voyons maintenant comment une telle contrainte peut être gérée à un niveau d'agrégation supérieur. Faisons l'hypothèse que le palier de MOQ est fourni dans le fichier "articles". Puisque la contrainte de MOQ s'applique à un certain niveau de regroupement, nous faisons l'hypothèse, à des fins de cohérence, que tous les articles qui appartiennent au même groupe de MOQ ont la même valeur de MOQ. Le script ci-dessous illustre la gestion d'une contrainte sur plusieurs SKU.

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

// coupure...

G.Eligible = solve.moq(
Item: Id
Quantity: G.Min
Reward: G.Reward
Cost: G.Cost
MaxCost: budget
GroupId: SubCategory
GroupQuantity: G.Q
// quantité minimale de commande par sous-catégorie
GroupMinQuantity: MinOrderQuantity) 

// coupure...



Le script ci-dessus est en fait presque identique à celui de la section précédente. Pour plus de clarté, seul l'appel de moqsolv est affiché car c'est la seule modification. Cette fonction reçoit une autre contrainte de quantité de commande minimale en argument.

Multiples de commande par SKU

Par fois les SKU ne peuvent être commandées qu'en certaines quantités et, à la différence de la contrainte de quantité de commande minimale, la quantité commandée doit être un multiple d'une quantité de "base". Par exemple, un produit ne peut être commandé que par caisses de 12 unités. Il est impossible d'en commander 13 unités, seulement 12 ou 24. Nous appelons la quantité à multiplier "multiple de commande". Il est également possible d'ajuster la logique de priorisation pour se plier à cette contrainte.

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

LotMultiplier = 5


//Filtrage des commandes d'achat clôturées
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

// % relatif au prix de vente
oosPenalty := 0.25 
// % des coûts de stockage annuels relatif au prix de vente
carryingCost := 0.3 
// % de remise économique annuelle
discount := 0.20 

M = SellPrice - BuyPrice
// pénalité de rupture de stock
S = - 0.25 * SellPrice 
// % '0.3' de coûts de stockage annuels
C = - 0.3 * BuyPrice * mean(Leadtime) / 365 
// cas de commande en souffrance
MB = 0.5 * SellPrice 
MBU = MB * uniform(1, Backorder)
// cas de commande en souffrance
SB = 0.5 * SellPrice 
SBU = SB * uniform(1, Backorder)
// opportunité d'achat ultérieur
AM = 0.3 
// % '0.2' de remise économique annuelle
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

// le troisième argument est un multiple de commande
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
// contraint de quantité minimale de commande fictive
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

Le script exploite un fonctionnement spécial de la fonction extend.distrib(), conçue pour prendre en compte les multiples de commande. Le troisième argument de cette fonction représente la valeur du multiple de commande

Capacité cible de container par fournisseur

Aux importations est souvent associée la contrainte d'acheter un container entier, ou la moitié d'un. Le volume du container est connu et, dans cet exemple, nous faisons l'hypothèse que les volumes de tous les articles achetés sont connus également. Le but est d'élaborer une liste d'articles qui représente le contenu du prochain container plein commandé.

Pour complexifier les choses, faisons l'hypothèse qu'il n'est pas possible de regrouper des commandes passées à différents fournisseurs. Ainsi, pour composer le contenu d'un container, toutes les lignes d'achat doivent être associées au même fournisseur. Cela implique d'identifier le fournisseur le plus pressant puis de remplir le container en fonction de ce dernier. Supposons que le fichier « articles » (items) contient une colonne Supplier (fournisseur) qui indique le fournisseur principal, dans un scénario où chaque article est associé à exactement un fournisseur.

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

//Filtrage des commandes d'achat clôturées
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 du container (m3)
cVolume := 15 

// % relatif au prix de vente
oosPenalty := 0.25 
// % des coûts de stockage annuels relatif au prix de vente
carryingCost := 0.3 
// % de remise économique annuelle
discount := 0.20 

M = SellPrice - BuyPrice
// pénalité de rupture de stock
S = - 0.25 * SellPrice 
// % '0.3' de coûts de stockage annuels
C = - 0.3 * BuyPrice * mean(Leadtime) / 365 
// cas de commande en souffrance
MB = 0.5 * SellPrice 
MBU = MB * uniform(1, Backorder)
// cas de commande en souffrance
SB = 0.5 * SellPrice 
SBU = SB * uniform(1, Backorder)
// opportunité d'achat ultérieur
AM = 0.3 
// % '0.2' de remise économique annuelle
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 // 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) // récompense
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' pour Supplier (fournisseur)
G.CId = priopack(G.V, cV, cJT, Id) by S sort G.Rk

// remplissage du container pour le fournisseur
// le plus prioritaire
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

Ce tableau de bord ne contient qu'un seul tableau qui liste les lots par ordre décroissant de récompense. Les récompenses associées au stock sont calculées à partir de la fonction stockrwd. La logique de regroupement par lots — c’est-à-dire la répartition des quantités dans plusieurs containers — est déroulée par la fonction priopack. Cette dernière a été ajoutée au langage Envision spécialement pour gérer les contraintes qu'imposent les commandes par containers.