Project 4

Inventory Optimization

Analyze stock levels, apply ABC classification, and calculate reorder points and safety stock to reduce carrying costs while preventing stockouts.

Project Overview

Difficulty: Intermediate

Tools: SQL, Python (pandas, numpy, matplotlib), Excel

Estimated Time: 6-8 hours

You are a supply chain analyst at RetailMax, a multi-channel retailer with a warehouse operation serving both online and brick-and-mortar stores. The operations director has flagged two problems:

Your goal is to build an inventory optimization model that classifies products using ABC analysis, calculates reorder points and safety stock, and forecasts short-term demand using moving averages.

Dataset Description

The project uses inventory and sales data from the warehouse management system.

Table: inventory

Column Data Type Description
product_idINT (PK)Unique product identifier
product_nameVARCHAR(100)Product display name
categoryVARCHAR(50)Product category
current_stockINTUnits currently in warehouse
unit_costDECIMAL(10,2)Cost per unit
unit_priceDECIMAL(10,2)Selling price per unit
supplier_lead_time_daysINTDays to receive new stock from supplier
reorder_quantityINTCurrent standard reorder amount

Table: daily_sales

Column Data Type Description
sale_idINT (PK)Unique sale record identifier
product_idINT (FK)References inventory table
sale_dateDATEDate of the sale
quantity_soldINTUnits sold on this date
channelVARCHAR(20)online, in_store, wholesale

Table: stockouts_log

Column Data Type Description
stockout_idINT (PK)Unique stockout event identifier
product_idINT (FK)Product that went out of stock
stockout_dateDATEDate stock hit zero
days_out_of_stockINTNumber of days unavailable
estimated_lost_salesINTEstimated units of demand missed

SQL Analysis

These queries extract the key metrics needed for inventory optimization decisions.

Query 1: Sales Velocity and Stock Coverage

Calculates average daily sales per product and how many days of stock remain at the current sell rate.

SELECT
    i.product_id,
    i.product_name,
    i.category,
    i.current_stock,
    ROUND(AVG(ds.quantity_sold), 2)          AS avg_daily_sales,
    ROUND(STDDEV(ds.quantity_sold), 2)       AS sales_std_dev,
    CASE
        WHEN AVG(ds.quantity_sold) > 0
        THEN ROUND(i.current_stock / AVG(ds.quantity_sold), 1)
        ELSE NULL
    END                                       AS days_of_stock_remaining,
    i.supplier_lead_time_days
FROM inventory i
LEFT JOIN daily_sales ds ON i.product_id = ds.product_id
    AND ds.sale_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
GROUP BY i.product_id, i.product_name, i.category,
         i.current_stock, i.supplier_lead_time_days
ORDER BY days_of_stock_remaining ASC;

Query 2: ABC Classification by Revenue

Classifies products into A (top 80% of revenue), B (next 15%), and C (bottom 5%) categories using cumulative percentages.

WITH product_revenue AS (
    SELECT
        i.product_id,
        i.product_name,
        SUM(ds.quantity_sold * i.unit_price) AS total_revenue
    FROM inventory i
    JOIN daily_sales ds ON i.product_id = ds.product_id
    GROUP BY i.product_id, i.product_name
),
ranked AS (
    SELECT *,
        SUM(total_revenue) OVER (ORDER BY total_revenue DESC) AS cumulative_revenue,
        SUM(total_revenue) OVER ()                             AS grand_total
    FROM product_revenue
)
SELECT
    product_id,
    product_name,
    total_revenue,
    ROUND(cumulative_revenue / grand_total * 100, 2) AS cumulative_pct,
    CASE
        WHEN cumulative_revenue / grand_total <= 0.80 THEN 'A'
        WHEN cumulative_revenue / grand_total <= 0.95 THEN 'B'
        ELSE 'C'
    END AS abc_class
FROM ranked
ORDER BY total_revenue DESC;

Query 3: Slow-Moving and Dead Stock

Identifies products with high stock levels but minimal recent sales, candidates for clearance or discontinuation.

SELECT
    i.product_id,
    i.product_name,
    i.current_stock,
    i.current_stock * i.unit_cost  AS inventory_value,
    COALESCE(SUM(ds.quantity_sold), 0) AS units_sold_90_days,
    CASE
        WHEN COALESCE(SUM(ds.quantity_sold), 0) = 0 THEN 'Dead Stock'
        WHEN COALESCE(SUM(ds.quantity_sold), 0) < 10 THEN 'Slow Moving'
        ELSE 'Active'
    END AS stock_status
FROM inventory i
LEFT JOIN daily_sales ds ON i.product_id = ds.product_id
    AND ds.sale_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
GROUP BY i.product_id, i.product_name, i.current_stock, i.unit_cost
HAVING stock_status IN ('Dead Stock', 'Slow Moving')
ORDER BY inventory_value DESC;

Query 4: Stockout Impact Analysis

Quantifies the business impact of stockouts by product, showing both frequency and estimated lost revenue.

SELECT
    i.product_name,
    i.category,
    COUNT(sl.stockout_id)               AS stockout_events,
    SUM(sl.days_out_of_stock)           AS total_days_out,
    SUM(sl.estimated_lost_sales)        AS total_lost_units,
    SUM(sl.estimated_lost_sales * i.unit_price) AS estimated_lost_revenue
FROM stockouts_log sl
JOIN inventory i ON sl.product_id = i.product_id
GROUP BY i.product_name, i.category
ORDER BY estimated_lost_revenue DESC
LIMIT 15;

Python Analysis

Use Python to perform ABC analysis, calculate reorder points and safety stock, and create demand forecasts.

Data Loading and ABC Analysis

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# Load data
inventory = pd.read_csv('inventory.csv')
daily_sales = pd.read_csv('daily_sales.csv', parse_dates=['sale_date'])

# Calculate total revenue per product
product_revenue = daily_sales.merge(inventory[['product_id', 'unit_price']], on='product_id')
product_revenue['revenue'] = product_revenue['quantity_sold'] * product_revenue['unit_price']

abc = product_revenue.groupby('product_id')['revenue'].sum().reset_index()
abc = abc.merge(inventory[['product_id', 'product_name', 'category']], on='product_id')
abc = abc.sort_values('revenue', ascending=False).reset_index(drop=True)

# Calculate cumulative percentage
abc['cumulative_revenue'] = abc['revenue'].cumsum()
abc['cumulative_pct'] = abc['cumulative_revenue'] / abc['revenue'].sum() * 100

# Assign ABC class
abc['abc_class'] = np.where(
    abc['cumulative_pct'] <= 80, 'A',
    np.where(abc['cumulative_pct'] <= 95, 'B', 'C')
)

# Summary
abc_summary = abc.groupby('abc_class').agg(
    product_count=('product_id', 'count'),
    total_revenue=('revenue', 'sum')
).reset_index()
abc_summary['revenue_pct'] = (abc_summary['total_revenue'] / abc_summary['total_revenue'].sum() * 100).round(1)
abc_summary['product_pct'] = (abc_summary['product_count'] / abc_summary['product_count'].sum() * 100).round(1)

print("ABC Analysis Summary:")
print(abc_summary.to_string(index=False))

Reorder Point and Safety Stock Calculation

# Calculate daily demand statistics per product (last 90 days)
cutoff = daily_sales['sale_date'].max() - pd.Timedelta(days=90)
recent = daily_sales[daily_sales['sale_date'] >= cutoff]

demand_stats = recent.groupby('product_id').agg(
    avg_daily_demand=('quantity_sold', 'mean'),
    std_daily_demand=('quantity_sold', 'std'),
    total_sold=('quantity_sold', 'sum')
).reset_index()

# Merge with inventory data
inv_analysis = inventory.merge(demand_stats, on='product_id', how='left')
inv_analysis = inv_analysis.merge(abc[['product_id', 'abc_class']], on='product_id', how='left')
inv_analysis['std_daily_demand'] = inv_analysis['std_daily_demand'].fillna(0)

# Service level Z-scores: A=99% (2.33), B=95% (1.65), C=90% (1.28)
z_scores = {'A': 2.33, 'B': 1.65, 'C': 1.28}
inv_analysis['z_score'] = inv_analysis['abc_class'].map(z_scores)

# Safety Stock = Z * std_demand * sqrt(lead_time)
inv_analysis['safety_stock'] = (
    inv_analysis['z_score'] *
    inv_analysis['std_daily_demand'] *
    np.sqrt(inv_analysis['supplier_lead_time_days'])
).round(0).astype(int)

# Reorder Point = (avg_daily_demand * lead_time) + safety_stock
inv_analysis['reorder_point'] = (
    inv_analysis['avg_daily_demand'] * inv_analysis['supplier_lead_time_days'] +
    inv_analysis['safety_stock']
).round(0).astype(int)

# Flag items that need reordering now
inv_analysis['needs_reorder'] = inv_analysis['current_stock'] <= inv_analysis['reorder_point']

reorder_now = inv_analysis[inv_analysis['needs_reorder']]
print(f"\nProducts needing immediate reorder: {len(reorder_now)}")
print(reorder_now[['product_name', 'abc_class', 'current_stock',
                    'reorder_point', 'safety_stock']].to_string(index=False))

Demand Forecasting with Moving Averages

# Weekly demand aggregation for a sample product
sample_product = inv_analysis.loc[inv_analysis['abc_class'] == 'A', 'product_id'].iloc[0]
product_sales = daily_sales[daily_sales['product_id'] == sample_product].copy()
product_sales = product_sales.set_index('sale_date').resample('W')['quantity_sold'].sum()

# Calculate moving averages
product_sales_df = product_sales.reset_index()
product_sales_df.columns = ['week', 'units_sold']
product_sales_df['ma_4week'] = product_sales_df['units_sold'].rolling(4).mean()
product_sales_df['ma_8week'] = product_sales_df['units_sold'].rolling(8).mean()

# Forecast next 4 weeks using weighted moving average
weights = [0.4, 0.3, 0.2, 0.1]  # More weight to recent weeks
last_4 = product_sales_df['units_sold'].tail(4).values
forecast = sum(w * v for w, v in zip(weights, last_4))
print(f"\n4-week weighted forecast for product {sample_product}: {forecast:.0f} units/week")

# Visualization
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# Plot 1: ABC Pareto Chart
ax1 = axes[0, 0]
colors_abc = {'A': '#e07a2f', 'B': '#4a90d9', 'C': '#999999'}
for cls in ['A', 'B', 'C']:
    mask = abc['abc_class'] == cls
    ax1.bar(abc.loc[mask].index, abc.loc[mask, 'revenue'], color=colors_abc[cls], label=f'Class {cls}')
ax1_twin = ax1.twinx()
ax1_twin.plot(abc.index, abc['cumulative_pct'], color='red', linewidth=2)
ax1_twin.axhline(y=80, color='red', linestyle='--', alpha=0.5)
ax1_twin.axhline(y=95, color='red', linestyle='--', alpha=0.5)
ax1_twin.set_ylabel('Cumulative %')
ax1.set_title('ABC Analysis - Pareto Chart', fontweight='bold')
ax1.set_ylabel('Revenue ($)')
ax1.legend()

# Plot 2: Demand Forecast
ax2 = axes[0, 1]
ax2.plot(product_sales_df['week'], product_sales_df['units_sold'],
         label='Actual', color='#333', alpha=0.5)
ax2.plot(product_sales_df['week'], product_sales_df['ma_4week'],
         label='4-Week MA', color='#e07a2f', linewidth=2)
ax2.plot(product_sales_df['week'], product_sales_df['ma_8week'],
         label='8-Week MA', color='#4a90d9', linewidth=2)
ax2.set_title('Demand Forecast (Moving Averages)', fontweight='bold')
ax2.set_ylabel('Units Sold')
ax2.legend()
plt.setp(ax2.xaxis.get_majorticklabels(), rotation=45)

# Plot 3: Current Stock vs Reorder Point
ax3 = axes[1, 0]
top_a = inv_analysis[inv_analysis['abc_class'] == 'A'].head(10)
x = range(len(top_a))
ax3.bar(x, top_a['current_stock'], label='Current Stock', color='#4a90d9', width=0.4)
ax3.bar([i + 0.4 for i in x], top_a['reorder_point'], label='Reorder Point',
        color='#e07a2f', width=0.4)
ax3.set_xticks([i + 0.2 for i in x])
ax3.set_xticklabels(top_a['product_name'].str[:15], rotation=45, ha='right', fontsize=8)
ax3.set_title('Stock Level vs Reorder Point (Class A)', fontweight='bold')
ax3.set_ylabel('Units')
ax3.legend()

# Plot 4: Inventory Value by ABC Class
ax4 = axes[1, 1]
inv_analysis['inventory_value'] = inv_analysis['current_stock'] * inv_analysis['unit_cost']
class_value = inv_analysis.groupby('abc_class')['inventory_value'].sum()
ax4.pie(class_value, labels=[f'Class {c}\n${v:,.0f}' for c, v in class_value.items()],
        colors=[colors_abc[c] for c in class_value.index],
        autopct='%1.1f%%', startangle=90)
ax4.set_title('Inventory Value by ABC Class', fontweight='bold')

plt.suptitle('Inventory Optimization Dashboard', fontsize=16, fontweight='bold')
plt.tight_layout()
plt.savefig('inventory_optimization.png', dpi=150)
plt.show()

Excel Dashboard Instructions

Build an inventory management dashboard that the operations team can use daily to monitor stock levels and trigger reorders.

Step-by-Step Guide

  • Import Data: Import inventory.csv and daily_sales.csv into separate sheets. Create a merged sheet using VLOOKUP or XLOOKUP to combine inventory details with 90-day sales totals per product.
  • ABC Classification: Calculate total revenue per product (=SUMIFS of quantity_sold * unit_price). Sort descending by revenue. Add a cumulative sum column and cumulative percentage column. Use =IF(cum_pct<=80,"A",IF(cum_pct<=95,"B","C")) to assign ABC classes.
  • Demand Statistics: Use =AVERAGEIFS to calculate avg_daily_demand for each product from the last 90 days. Use a helper column with =STDEV.S on filtered ranges (or STDEV.S array formula) for standard deviation.
  • Safety Stock Calculation: Add a lookup table with Z-scores: A=2.33, B=1.65, C=1.28. Calculate safety_stock = VLOOKUP(abc_class, z_table, 2, FALSE) * std_daily_demand * SQRT(lead_time_days).
  • Reorder Point: Calculate reorder_point = (avg_daily_demand * lead_time_days) + safety_stock. Add a column: =IF(current_stock <= reorder_point, "REORDER NOW", "OK") with conditional formatting (red for REORDER NOW).
  • Days of Stock Remaining: Add a column =IF(avg_daily_demand>0, current_stock / avg_daily_demand, "N/A"). Apply conditional formatting: red for <7 days, yellow for 7-14, green for >14.
  • Inventory Value Analysis: Create a PivotTable with abc_class in Rows, and Sum of inventory_value (current_stock * unit_cost) in Values. Insert a Pie Chart showing the distribution of inventory capital by ABC class.
  • Reorder Alert Dashboard: Create a filtered view showing only products where current_stock <= reorder_point, sorted by days_of_stock_remaining ascending. Add columns for suggested_order_quantity and estimated_cost.
  • Demand Trend Charts: For the top 5 Class A products, create a line chart showing weekly sales over the past 12 weeks. Add a trendline (moving average, period=4) to visualize demand direction.
  • KPI Summary Row: At the top of the dashboard, show: Total Inventory Value, Number of Items Needing Reorder, Dead Stock Value, and Average Days of Stock. Use SUMPRODUCT and COUNTIFS formulas to calculate these dynamically.
  • Key Insights & Recommendations

    Present these findings to the operations and finance teams to drive inventory optimization decisions.

    ABC Concentration

    Class A products (typically 15-20% of SKUs) generate 80% of revenue. These items need the highest service levels (99% fill rate), tightest monitoring, and most accurate forecasts. Class C items can be managed with simpler rules.

    Safety Stock Savings

    By differentiating service levels by ABC class instead of applying a blanket 99% across all products, the company can reduce safety stock investment by an estimated 20-25% while maintaining service levels where it matters most.

    Dead Stock Liquidation

    Products with zero sales in 90 days and current stock should be flagged for clearance pricing, donation, or write-off. Each month of holding dead stock costs approximately 2% of its value in warehousing and opportunity costs.

    Stockout Prevention

    The calculated reorder points account for both average demand and variability. Products with high demand variability need proportionally more safety stock. Automating reorder alerts at the reorder point eliminates manual monitoring delays.

    Demand Forecasting

    The weighted moving average gives more importance to recent sales trends. For seasonal products, consider using the same period from last year as an additional input. Review forecasts monthly and adjust weights as patterns change.

    Supplier Lead Time Risk

    Products with long supplier lead times (15+ days) and high demand variability are the highest risk for stockouts. Consider dual-sourcing these items or negotiating reduced lead times, even at slightly higher unit costs.

    Knowledge Check Quiz

    Test your understanding of inventory optimization concepts.

    Question 1

    In ABC analysis, Class A products typically represent what share of total revenue?

    Question 2

    What is the purpose of safety stock?

    Question 3

    The reorder point formula is: (avg_daily_demand x lead_time) + safety_stock. What does the first part (avg_daily_demand x lead_time) represent?

    Question 4

    In the safety stock formula, why do we use the square root of lead time (sqrt(lead_time)) rather than lead time directly?

    Question 5

    A product has 0 units sold in the last 90 days but 500 units in stock worth $15,000. What is the recommended action?

    ← Previous Project Next Project →