A multi-stage inventory management model based on the example of optimizing fertilizer production
Introduction
This example shows how to use Engee to create an intelligent production and inventory planning model. It is necessary to make strategic decisions for the whole year ahead.
Let's imagine that we run a factory that produces two types of fertilizers. We have several types of raw materials (ingredients) at our disposal, the price of which varies from month to month according to a well—known schedule (for example, seasonally). At the same time, we know exactly how many tons of each type of fertilizer customers will order each month. The
goal is to maximize profits. To do this, you need to find the perfect balance between:
- Produce exactly as much as you need (or you can sell it profitably);
- Purchase of raw materials in the cheapest months;
- Storing finished products in a warehouse when it is economically feasible.
For such a long-term strategy, you can use financial instruments (for example, futures contracts) to fix commodity prices for the future and protect yourself from risks. Our mathematical model will help you calculate the most profitable plan, taking into account all these factors.
We will connect the necessary libraries.
import Pkg
neededLibs = ["JuMP" ,"GLPK", "NamedArrays", "Measures" ]
for lib in neededLibs
try
eval(Meta.parse("using $lib"))
catch ex
Pkg.add(lib)
eval(Meta.parse("using $lib"))
end
end
Fertilizers and their composition
Granular fertilizers contain three key nutrients: nitrogen, phosphorus and potassium. At the factory, we do not produce them from scratch, but mix ready—made raw materials to obtain commercial brands of fertilizers with the right composition.
We have several "ingredients" - each with its own unique set of nutrients. By combining them in the right proportions, we create a finished product — a balanced fertilizer that our customers need.
Initial data
We will determine the data on the basis of which we will optimize production.:
- the amount of demand for fertilizers;
- list of months in a year;
- Initial stock values;
- percentage of nutrients in each type of fertilizer;
- the size of fertilizer orders;
- prices for fertilizers;
- Storage capacity;
- cost of storage;
- Production capacity;
- cost of raw materials;
- the content of nutrients in the raw materials.
спрос_на_удобрения = DataFrame(
Сбалансированное = [750, 800, 900, 850, 700, 700, 700, 600, 600, 550, 550, 550],
Высокоазотное = [300, 310, 600, 400, 350, 300, 200, 200, 200, 200, 200, 200]
)
месяцы = ["Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"]
начальные_запасы = DataFrame(
Сбалансированное = [200, 200],
Высокоазотное = [200, 200]
)
содержание_питательных_в_удобрениях = DataFrame(
Сбалансированное = [10, 10, 10],
Высокоазотное = [20, 10, 10]
)
заказы_на_удобрения = DataFrame(
Сбалансированное = [200, 400, 400, 400, 400, 400, 200, 0, 0, 0, 0, 0],
Высокоазотное = [0, 100, 200, 200, 200, 200, 200, 0, 0, 0, 0, 0]
)
цены_на_удобрения = DataFrame(
Сбалансированное = [400],
Высокоазотное = [550]
)
вместимость_склада = 1000
стоимость_хранения_единицы = 10
производственная_мощность = 1200
стоимость_сырья = DataFrame(
Моноаммонийфосфат = [350, 360, 350, 350, 320, 320, 320, 320, 320, 310, 310, 340],
Хлорид_калия = [610, 630, 630, 610, 600, 600, 600, 600, 600, 600, 600, 600],
Аммиачная_селитра = [300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300, 300],
Сульфат_аммония = [135, 140, 135, 125, 125, 125, 125, 125, 125, 125, 125, 125],
Тройной_суперфосфат = [250, 275, 275, 250, 250, 250, 250, 240, 240, 240, 240, 240],
Песок = [80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80]
)
содержание_питательных_в_сырье = DataFrame(
Моноаммонийфосфат = [11, 48, 0],
Хлорид_калия = [0, 0, 60],
Аммиачная_селитра = [35, 0, 0],
Сульфат_аммония = [21, 0, 0],
Тройной_суперфосфат = [0, 46, 0],
Песок = [0, 0, 0]
)
виды_удобрений = names(спрос_на_удобрения)
вещества = ["Азот", "Фосфор", "Калий"]
виды_сырья = names(стоимость_сырья)
количество_видов_удобрений = length(виды_удобрений)
количество_видов_сырья = length(виды_сырья)
количество_питательных = length(вещества)
количество_месяцев = length(месяцы)
спрос_с_месяцами = copy(спрос_на_удобрения)
insertcols!(спрос_с_месяцами, 1, :Месяц => месяцы)
стоимость_сырья_с_месяцами = copy(стоимость_сырья)
insertcols!(стоимость_сырья_с_месяцами, 1, :Месяц => месяцы)
содержание_удобрений_с_названиями = copy(содержание_питательных_в_удобрениях)
insertcols!(содержание_удобрений_с_названиями, 1, :Питательное_вещество => вещества)
содержание_сырья_с_названиями = copy(содержание_питательных_в_сырье)
insertcols!(содержание_сырья_с_названиями, 1, :Питательное_вещество => вещества)
Let's define a function for displaying data in the form of a table.
function вывести_таблицу(df::DataFrame, заголовок::String="")
if !isempty(заголовок)
println("\n$заголовок:")
end
имена_столбцов = names(df)
ширина_столбцов = []
for столбец in имена_столбцов
ширина = length(string(столбец))
for i in 1:nrow(df)
значение = df[i, столбец]
if isa(значение, Number)
строка_значения = string(round(Int, значение))
else
строка_значения = string(значение)
end
ширина = max(ширина, length(строка_значения))
end
push!(ширина_столбцов, ширина + 2)
end
заголовок_таблицы = ""
for (i, столбец) in enumerate(имена_столбцов)
заголовок_таблицы *= rpad(столбец, ширина_столбцов[i])
end
println(заголовок_таблицы)
разделитель = ""
for ширина in ширина_столбцов
разделитель *= repeat("-", ширина)
end
println(разделитель)
for i in 1:nrow(df)
строка = ""
for (j, столбец) in enumerate(имена_столбцов)
значение = df[i, столбец]
if isa(значение, Number)
строка_значения = string(round(Int, значение))
else
строка_значения = string(значение)
end
строка *= rpad(строка_значения, ширина_столбцов[j])
end
println(строка)
end
println()
end
There are two types of fertilizers available:
-
Balanced — the standard composition is 10% nitrogen, 10% phosphorus, 10% potassium.
-
High nitrogen — nitrogen-enhanced version: 20% nitrogen, 10% phosphorus, 10% potassium.
The only difference is the doubled proportion of nitrogen in "High Nitrogen", which makes it more expensive to produce.
println("Содержание питательных веществ в удобрениях (в процентах):\n")
вывести_таблицу(содержание_удобрений_с_названиями)
Raw materials and their nutritional composition (in % by weight):
println("Содержание питательных веществ в сырье (в процентах):\n")
вывести_таблицу(содержание_сырья_с_названиями)
Sand is a neutral filler. There are no nutrients in it. It is used as a "diluent" to accurately maintain the required percentage in the finished mixture when the active ingredients are too concentrated.
Volume of orders and prices for fertilizers
For the entire planning period, we know the exact monthly order volume for each brand of fertilizers.
println("Order volume:")
вывести_таблицу(спрос_с_месяцами)
Prices are fixed for the whole year. This means that our revenue depends only on the volume of sales, not on time. This stability simplifies planning and allows you to focus on cost optimization.
println("Fertilizer prices:")
вывести_таблицу(цены_на_удобрения)
The cost of raw materials
Raw material prices are a key variable in our model. Unlike stable prices for finished products, the cost of ingredients changes every month (for example, due to seasonality or market conditions).
println("The cost of raw materials:")
вывести_таблицу(стоимость_сырья_с_месяцами)
Production and storage
We will display the cost of unit storage, warehouse capacity, and production capacity.
println("Стоимость хранения единицы: $стоимость_хранения_единицы")
println("Вместимость склада: $вместимость_склада")
println("Производственная мощность: $производственная_мощность")
If we were unable to produce and ship the entire volume of orders in one month, the balance will not be transferred to the next month. This forces the model to always plan production ahead of demand. Thus, the model is looking for a balance: to produce enough to fulfill orders and reach the target balances, but not to produce too much, so as not to incur storage costs.
Setting the task
The model is based on an objective function that needs to be maximized. In our case, it's a profit.
модель = Model(GLPK.Optimizer)
Variables
The variables in the task are the volumes of fertilizer mixtures that we produce and sell each month, as well as the raw materials used to make them.
@variable(модель, производить[1:количество_месяцев, 1:количество_видов_удобрений] >= 0)
@variable(модель, продавать[1:количество_месяцев, 1:количество_видов_удобрений] >= 0)
@variable(модель, использовать[1:количество_месяцев, 1:количество_видов_сырья, 1:количество_видов_удобрений] >= 0)
In addition, we will create a variable representing the amount of inventory at each point in time.
@variable(модель, 0 <= запасы[1:количество_месяцев, 1:количество_видов_удобрений] <= вместимость_склада)
The upper limit for sales is the demand for each time period and each brand of fertilizer.
for i in 1:количество_месяцев, b in 1:количество_видов_удобрений
set_upper_bound(продавать[i, b], спрос_на_удобрения[i, b])
end
Expressions
To calculate the objective function using task variables, you need to calculate revenue and costs. Revenue is the sales volume of each type of fertilizer multiplied by its price, summed up over all time periods and all types of fertilizers. Let's define an expression for revenue.
@expression(модель, выручка, sum(цены_на_удобрения[1, j] * sum(продавать[i, j] for i in 1:количество_месяцев) for j in 1:количество_видов_удобрений))
Let's define an expression for the cost of raw materials. The cost of raw materials is the cost of each ingredient used at each point in time, summed up over all periods. Since the volume of raw materials used at each moment is divided by the amount used for each fertilizer, the summation is also carried out by types of fertilizers.
@expression(модель, сырье_использовано[i=1:количество_месяцев, r=1:количество_видов_сырья], sum(использовать[i, r, b] for b in 1:количество_видов_удобрений))
@expression(модель, стоимость_сырья_общая, sum(стоимость_сырья[i, r] * сырье_использовано[i, r] for i in 1:количество_месяцев for r in 1:количество_видов_сырья))
Let's define an expression for storage costs. Storage costs are the cost of maintaining stocks during each time period, summed up by time and types of fertilizers.
@expression(модель, стоимость_хранения_общая, стоимость_хранения_единицы * sum(запасы))
Target function
Let's define the objective function.
@objective(модель, Max, выручка - стоимость_сырья_общая - стоимость_хранения_общая)
Limitations
The model must comply with key production rules.:
- Inventory balance: the balance in the warehouse is the sum of the balance of the previous month and the difference in the quantities of products produced and sold;
- target stocks: by the end of the year, it is necessary to reach the set balance level;
- Limits: Warehouse capacity and factory capacity are limited;
- balance of materials: the amount of finished products is equal to the amount of consumed raw materials;
- Quality: the chemical composition of each batch must strictly correspond to the formulation.
All these rules are formalized into mathematical constraints and loaded into the model to find the optimal plan.
@constraint(модель, материальный_баланс1[i=2:количество_месяцев, b=1:количество_видов_удобрений],
запасы[i, b] == запасы[i-1, b] + производить[i, b] - продавать[i, b])
@constraint(модель, материальный_баланс2[b=1:количество_видов_удобрений],
запасы[1, b] == начальные_запасы[1, b] + производить[1, b] - продавать[1, b])
@constraint(модель, финальные_запасы[b=1:количество_видов_удобрений], запасы[количество_месяцев, b] == начальные_запасы[2, b])
@constraint(модель, ограничение_склада[i=1:количество_месяцев], sum(запасы[i, :]) <= вместимость_склада)
@constraint(модель, ограничение_мощности[i=1:количество_месяцев], sum(производить[i, :]) <= производственная_мощность)
@constraint(модель, использование_сырья[i=1:количество_месяцев, b=1:количество_видов_удобрений],
sum(использовать[i, r, b] for r in 1:количество_видов_сырья) == производить[i, b])
@constraint(модель, качество_удобрений[i=1:количество_месяцев, n=1:количество_питательных, b=1:количество_видов_удобрений],
sum(содержание_питательных_в_сырье[n, r] * использовать[i, r, b] for r in 1:количество_видов_сырья) == содержание_питательных_в_удобрениях[n, b] * производить[i, b])
Solving and visualizing the results
Let's start solving the optimization problem.
optimize!(модель)
Let's display the solution as a table (if the table does not fit in width, zoom out in the browser).
статус = termination_status(модель);
if статус == MOI.OPTIMAL
прибыль = round(Int, objective_value(модель))
println("\nОптимальное решение найдено")
println("Прибыль: $прибыль")
значения_производить = value.(производить)
значения_продавать = value.(продавать)
значения_запасы = value.(запасы)
производить_округленные = round.(Int, значения_производить)
продавать_округленные = round.(Int, значения_продавать)
запасы_округленные = round.(Int, значения_запасы)
таблица_производства = DataFrame()
for b in 1:количество_видов_удобрений
таблица_производства[!, Symbol("произвести_" * replace(виды_удобрений[b], " " => "_"))] = производить_округленные[:, b]
end
таблица_продаж = DataFrame()
for b in 1:количество_видов_удобрений
таблица_продаж[!, Symbol("продать_" * replace(виды_удобрений[b], " " => "_"))] = продавать_округленные[:, b]
end
таблица_запасов = DataFrame()
for b in 1:количество_видов_удобрений
таблица_запасов[!, Symbol("запасы_" * replace(виды_удобрений[b], " " => "_"))] = запасы_округленные[:, b]
end
производственный_план = hcat(таблица_производства, таблица_продаж, таблица_запасов)
производственный_план[!, :Месяц] = месяцы
производственный_план = производственный_план[:, [end, 1:end-1...]]
println("\nПроизводственный план:")
вывести_таблицу(производственный_план)
end
производить_сбалансированное = производить_округленные[:, 1];
производить_высокоазотное = производить_округленные[:, 2];
продавать_сбалансированное = продавать_округленные[:, 1];
продавать_высокоазотное = продавать_округленные[:, 2];
запасы_сбалансированное = запасы_округленные[:, 1];
запасы_высокоазотное = запасы_округленные[:, 2];
We will display a histogram of optimal production, sales, and inventory of a balanced fertilizer.
p1 = bar(месяцы, производить_сбалансированное, title="Produced by", xlabel="", color=:green, ylabel="Quantity")
p2 = bar(месяцы, продавать_сбалансированное, title="Sold", xlabel="", color=:green, ylabel="Quantity")
p3 = bar(месяцы, запасы_сбалансированное, title="Stocks", color=:green, ylabel="Quantity")
график1 = plot(p1, p2, p3, layout=(3, 1), size=(1000, 900), margin=10mm, legend=false)
display(график1)
We will display a histogram of optimal production, sales, and inventory of high nitrogen fertilizers.
p1 = bar(месяцы, производить_высокоазотное, title="Produced by", xlabel="", color=:orange, ylabel="Quantity")
p2 = bar(месяцы, продавать_высокоазотное, title="Sold", xlabel="", color=:orange, ylabel="Quantity")
p3 = bar(месяцы, запасы_высокоазотное, title="Stocks", color=:orange, ylabel="Quantity")
график2 = plot(p1, p2, p3, layout=(3, 1), size=(1000, 900), margin=10mm, legend=false)
display(график2)
Conclusion
The conducted modeling of a multi-stage production task demonstrates the effectiveness of optimization methods for strategic planning in a dynamic market. The solution provides an accurate and practical plan.
Key findings:
-
Release strategy: The model has identified the priority of producing high-margin products during periods of peak demand, while the base product supports overall capacity utilization. This is directly consistent with the goal of maximizing profits.
-
Dynamic role of reserves: The analysis showed that reserves do not serve as a reserve, but as an active buffer smoothing the imbalance between variable demand and fixed capacities. A strategy of planned accumulation and subsequent reduction of stocks is used to minimize total costs.
-
Impact of constraints: Tight constraints on capacity and target end balances form a specific structure of the plan, causing production spikes to meet the boundary conditions. This confirms the high sensitivity of the system to the control parameters.
Thus, the model adequately formalizes a complex planning task, transforming it into an optimization one. The result is a strategic map that identifies profit growth points and system bottlenecks, which makes this approach a valuable tool for justifying management decisions.

