关于 economic-order-quantity
用于采购与生产决策的经济订货量库存规划技能,涵盖经典 EOQ 及数量折扣、渐进式生产、延期交货和约束条件等变体。
主要功能
- 01经典 EOQ 计算
- 02数量折扣分析
- 03经济生产批量计算
- 04计划延期交货建模
- 05考虑约束的批量规划
适用场景
- 01平衡订货成本与持有成本
- 02比较供应商数量折扣
- 03确定生产批量
- 04评估计划延期交货
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ kishorkukreja/ awesome-supply-chain --skill economic-order-quantity安装后在 Claude Code 中输入/economic-order-quantity确认已加载。
详细介绍
经济订货量(EOQ)
你是经济订货量(EOQ)模型和库存批量优化方面的专家。你的目标是帮助确定最优订货量或生产量,通过平衡订货/准备成本与持有成本,将库存总成本降至最低。
初步评估
在计算 EOQ 之前,先了解:
-
业务背景
- 库存类型是什么?(原材料、成品、零部件)
- 当前的订货方式是什么?(固定数量、最小/最大值、临时订货)
- 主要成本关注点是什么?(订货、持有、缺货)
- 属于生产环境还是采购环境?
-
需求特征
- 年需求量是多少(件/年)?
- 需求模式是什么?(恒定、季节性、确定性与随机性)
- 需求率是否稳定?
- 计划周期是多长?
-
成本参数
- 商品的单位成本是多少($)?
- 每次订货成本($)或每次生产的准备成本是多少?
- 库存持有成本率是多少(每年 %)?
- 缺货或延期交货成本是多少(如适用)?
-
运营约束
- 供应商是否有最低订货量(MOQ)?
- 是否提供数量折扣?
- 是否有存储容量限制?
- 是否有生产率限制?
- 供应商的交货提前期是多少?
EOQ 模型基础
经典 EOQ 模型
假设:
- 需求恒定且已知,不存在不确定性
- 提前期恒定且已知
- 不允许缺货(缺货成本为无穷大)
- 订单一次性到货(即时补货)
- 没有数量折扣
- 持有成本和订货成本已知且恒定
决策: 哪个订货量 Q 能使总成本最低?
总成本函数
TC(Q) = Purchase Cost + Ordering Cost + Holding Cost
TC(Q) = DC + (D/Q)S + (Q/2)H
Where:
D = Annual demand (units/year)
C = Unit cost ($/unit)
S = Ordering cost per order ($)
H = Holding cost per unit per year ($/unit/year)
Q = Order quantity (units)
D/Q = Number of orders per year
Q/2 = Average inventory level (assuming uniform demand)
EOQ 公式推导
目标: 对 Q 求 TC(Q) 的最小值
对其求导并令导数为零:
dTC/dQ = -(DS)/Q² + H/2 = 0
Solving for Q:
-(DS)/Q² + H/2 = 0
H/2 = DS/Q²
Q² = 2DS/H
EOQ = √(2DS/H)
最优订货频率:
n* = D/EOQ = Number of orders per year
Cycle Time = 1/n* = EOQ/D years
最低总成本:
TC* = DC + √(2DSH)
Python 实现:经典 EOQ
基本 EOQ 计算器
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from typing import Dict, List, Tuple
class EOQModel:
"""
Classic Economic Order Quantity Model
Determines optimal order quantity to minimize total inventory costs
"""
def __init__(self, annual_demand: float, unit_cost: float,
ordering_cost: float, holding_cost_rate: float):
"""
Initialize EOQ model
Parameters:
-----------
annual_demand : float
Annual demand in units
unit_cost : float
Cost per unit ($)
ordering_cost : float
Fixed cost per order ($)
holding_cost_rate : float
Annual holding cost as fraction of unit cost (e.g., 0.25 = 25%)
"""
self.D = annual_demand
self.C = unit_cost
self.S = ordering_cost
self.r = holding_cost_rate
self.H = unit_cost * holding_cost_rate
def calculate_eoq(self) -> Dict:
"""Calculate Economic Order Quantity and associated metrics"""
# EOQ formula
eoq = np.sqrt((2 * self.D * self.S) / self.H)
# Number of orders per year
orders_per_year = self.D / eoq
# Cycle time (time between orders)
cycle_time_days = 365 / orders_per_year
# Cost components
purchase_cost = self.D * self.C
ordering_cost_total = (self.D / eoq) * self.S
holding_cost_total = (eoq / 2) * self.H
total_cost = purchase_cost + ordering_cost_total + holding_cost_total
# Total relevant cost (excluding purchase cost which is constant)
relevant_cost = ordering_cost_total + holding_cost_total
# Average inventory
avg_inventory = eoq / 2
avg_inventory_value = avg_inventory * self.C
return {
'eoq': round(eoq, 2),
'orders_per_year': round(orders_per_year, 2),
'cycle_time_days': round(cycle_time_days, 2),
'total_annual_cost': round(total_cost, 2),
'purchase_cost': round(purchase_cost, 2),
'ordering_cost': round(ordering_cost_total, 2),
'holding_cost': round(holding_cost_total, 2),
'relevant_cost': round(relevant_cost, 2),
'avg_inventory': round(avg_inventory, 2),
'avg_inventory_value': round(avg_inventory_value, 2),
'max_inventory': round(eoq, 2)
}
def sensitivity_analysis(self, param_name: str,
variation_range: Tuple[float, float],
num_points: int = 20) -> pd.DataFrame:
"""
Perform sensitivity analysis on a parameter
Parameters:
-----------
param_name : str
Parameter to vary: 'demand', 'ordering_cost', 'holding_rate', 'unit_cost'
variation_range : tuple
(min_multiplier, max_multiplier) e.g., (0.5, 2.0) for 50% to 200%
num_points : int
Number of points to evaluate
"""
multipliers = np.linspace(variation_range[0], variation_range[1], num_points)
results = []
# Store original values
orig_D, orig_S, orig_r, orig_C = self.D, self.S, self.r, self.C
for mult in multipliers:
# Vary parameter
if param_name == 'demand':
self.D = orig_D * mult
elif param_name == 'ordering_cost':
self.S = orig_S * mult
elif param_name == 'holding_rate':
self.r = orig_r * mult
self.H = self.C * self.r
elif param_name == 'unit_cost':
self.C = orig_C * mult
self.H = self.C * self.r
# Calculate EOQ at this parameter value
metrics = self.calculate_eoq()
results.append({
'multiplier': mult,
f'{param_name}_value': getattr(self, param_name.split('_')[0][0].upper()),
'eoq': metrics['eoq'],
'total_cost': metrics['total_annual_cost'],
'relevant_cost': metrics['relevant_cost'],
'orders_per_year': metrics['orders_per_year']
})
# Restore original values
self.D, self.S, self.r, self.C = orig_D, orig_S, orig_r, orig_C
self.H = self.C * self.r
return pd.DataFrame(results)
def plot_cost_curve(self, q_range: Tuple[float, float] = None):
"""
Plot total cost curve showing EOQ optimum
Parameters:
-----------
q_range : tuple, optional
(min_q, max_q) range for plotting. If None, uses EOQ ± 50%
"""
eoq_result = self.calculate_eoq()
eoq = eoq_result['eoq']
if q_range is None:
q_min = max(1, eoq * 0.3)
q_max = eoq * 2.0
else:
q_min, q_max = q_range
# Generate Q values
Q = np.linspace(q_min, q_max, 200)
# Calculate costs for each Q
ordering_costs = (self.D / Q) * self.S
holding_costs = (Q / 2) * self.H
total_relevant_costs = ordering_costs + holding_costs
# Plot
plt.figure(figsize=(12, 7))
plt.plot(Q, ordering_costs, label='Ordering Cost', linewidth=2, color='blue')
plt.plot(Q, holding_costs, label='Holding Cost', linewidth=2, color='green')
plt.plot(Q, total_relevant_costs, label='Total Relevant Cost',
linewidth=3, color='red')
# Mark EOQ
plt.axvline(x=eoq, color='purple', linestyle='--', linewidth=2,
label=f'EOQ = {eoq:.0f}')
plt.plot(eoq, eoq_result['relevant_cost'], 'ro', markersize=12,
label=f"Min Cost = ${eoq_result['relevant_cost']:,.0f}")
plt.xlabel('Order Quantity (Q)', fontsize=12)
plt.ylabel('Annual Cost ($)', fontsize=12)
plt.title('EOQ Cost Analysis', fontsize=14, fontweight='bold')
plt.legend(fontsize=10)
plt.grid(True, alpha=0.3)
plt.tight_layout()
return plt
def reorder_point(self, lead_time_days: float,
safety_stock: float = 0) -> Dict:
"""
Calculate reorder point given lead time
Parameters:
-----------
lead_time_days : float
Lead time in days
safety_stock : float
Safety stock quantity (default 0 for deterministic demand)
"""
daily_demand = self.D / 365
lead_time_demand = daily_demand * lead_time_days
rop = lead_time_demand + safety_stock
return {
'reorder_point': round(rop, 2),
'lead_time_demand': round(lead_time_demand, 2),
'safety_stock': safety_stock,
'daily_demand': round(daily_demand, 2)
}
# Example Usage
def example_basic_eoq():
"""Example: Basic EOQ calculation"""
# Create EOQ model
model = EOQModel(
annual_demand=10000, # 10,000 units per year
unit_cost=50, # $50 per unit
ordering_cost=100, # $100 per order
holding_cost_rate=0.25 # 25% per year
)
# Calculate EOQ
result = model.calculate_eoq()
print("=" * 60)
print("ECONOMIC ORDER QUANTITY ANALYSIS")
print("=" * 60)
print(f"\nInput Parameters:")
print(f" Annual Demand: {model.D:,} units")
print(f" Unit Cost: ${model.C:.2f}")
print(f" Ordering Cost: ${model.S:.2f}")
print(f" Holding Cost Rate: {model.r:.1%}")
print(f" Holding Cost per Unit: ${model.H:.2f}/unit/year")
print(f"\n{'Optimal Order Quantity (EOQ):':<35} {result['eoq']:>15,.0f} units")
print(f"{'Orders per Year:':<35} {result['orders_per_year']:>15,.2f} orders")
print(f"{'Days Between Orders:':<35} {result['cycle_time_days']:>15,.1f} days")
print(f"\n{'Cost Breakdown:':<35}")
print(f" {'Purchase Cost:':<33} ${result['purchase_cost']:>15,.2f}")
print(f" {'Ordering Cost:':<33} ${result['ordering_cost']:>15,.2f}")
print(f" {'Holding Cost:':<33} ${result['holding_cost']:>15,.2f}")
print(f" {'-'*50}")
print(f" {'Total Annual Cost:':<33} ${result['total_annual_cost']:>15,.2f}")
print(f"\n{'Inventory Levels:':<35}")
print(f" {'Average Inventory:':<33} {result['avg_inventory']:>15,.0f} units")
print(f" {'Average Inventory Value:':<33} ${result['avg_inventory_value']:>15,.2f}")
print(f" {'Maximum Inventory:':<33} {result['max_inventory']:>15,.0f} units")
# Calculate reorder point
rop = model.reorder_point(lead_time_days=14)
print(f"\n{'Reorder Point (14 day lead time):':<35} {rop['reorder_point']:>15,.0f} units")
# Plot cost curve
model.plot_cost_curve()
plt.savefig('/tmp/eoq_cost_curve.png', dpi=300, bbox_inches='tight')
print(f"\nCost curve saved to /tmp/eoq_cost_curve.png")
return model, result
if __name__ == "__main__":
model, result = example_basic_eoq()
带数量折扣的 EOQ
全量折扣模型
当供应商根据订货量提供价格折扣时,必须考虑较低单位成本与较高持有成本之间的权衡。
全量折扣: 折扣适用于订单中的所有商品。
class EOQWithQuantityDiscounts:
"""
EOQ model with all-units quantity discounts
Evaluates each price break to find the quantity that minimizes total cost
"""
def __init__(self, annual_demand: float, ordering_cost: float,
holding_cost_rate: float, price_breaks: List[Tuple[float, float]]):
"""
Parameters:
-----------
annual_demand : float
Annual demand in units
ordering_cost : float
Fixed cost per order ($)
holding_cost_rate : float
Annual holding cost as fraction of unit cost
price_breaks : list of tuples
[(min_qty, unit_price), ...] sorted by quantity
Example: [(0, 50), (500, 48), (1000, 45)]
"""
self.D = annual_demand
self.S = ordering_cost
self.r = holding_cost_rate
self.price_breaks = sorted(price_breaks, key=lambda x: x[0])
def calculate_optimal_quantity(self) -> Dict:
"""
Find order quantity that minimizes total cost
Algorithm:
1. For each price break, calculate EOQ at that price
2. If EOQ < break quantity, use break quantity
3. Calculate total cost for each feasible quantity
4. Select quantity with minimum total cost
"""
candidates = []
for i, (break_qty, unit_price) in enumerate(self.price_breaks):
H = unit_price * self.r
# Calculate EOQ at this price
eoq = np.sqrt((2 * self.D * self.S) / H)
# Determine feasible order quantity
# EOQ is feasible if it's >= current break and < next break
if i < len(self.price_breaks) - 1:
next_break_qty = self.price_breaks[i + 1][0]
if eoq >= break_qty and eoq < next_break_qty:
feasible_qty = eoq
else:
feasible_qty = break_qty
else:
# Last price break - EOQ is feasible if >= break qty
feasible_qty = max(eoq, break_qty)
# Calculate total cost at this quantity
if feasible_qty >= break_qty:
ordering_cost = (self.D / feasible_qty) * self.S
holding_cost = (feasible_qty / 2) * H
purchase_cost = self.D * unit_price
total_cost = purchase_cost + ordering_cost + holding_cost
candidates.append({
'break_quantity': break_qty,
'unit_price': unit_price,
'eoq_at_price': round(eoq, 2),
'feasible_quantity': round(feasible_qty, 2),
'purchase_cost': round(purchase_cost, 2),
'ordering_cost': round(ordering_cost, 2),
'holding_cost': round(holding_cost, 2),
'total_cost': round(total_cost, 2),
'orders_per_year': round(self.D / feasible_qty, 2)
})
# Find minimum cost option
candidates_df = pd.DataFrame(candidates)
optimal_idx = candidates_df['total_cost'].idxmin()
optimal = candidates_df.iloc[optimal_idx]
return {
'all_candidates': candidates_df,
'optimal': optimal.to_dict(),
'savings_vs_no_discount': self._calculate_savings(optimal)
}
def _calculate_savings(self, optimal: pd.Series) -> float:
"""Calculate savings compared to ordering at highest price (no discount)"""
highest_price = self.price_breaks[0][1]
H_base = highest_price * self.r
eoq_base = np.sqrt((2 * self.D * self.S) / H_base)
cost_base = (self.D * highest_price +
(self.D / eoq_base) * self.S +
(eoq_base / 2) * H_base)
savings = cost_base - optimal['total_cost']
savings_pct = (savings / cost_base) * 100
return round(savings_pct, 2)
def plot_discount_analysis(self):
"""Visualize total cost at different order quantities"""
result = self.calculate_optimal_quantity()
plt.figure(figsize=(14, 8))
# Generate cost curves for each price segment
for i, (break_qty, unit_price) in enumerate(self.price_breaks):
H = unit_price * self.r
# Define quantity range for this price
if i < len(self.price_breaks) - 1:
q_max = self.price_breaks[i + 1][0]
else:
q_max = break_qty * 3
Q = np.linspace(break_qty, q_max, 100)
# Calculate total cost
total_costs = (self.D * unit_price +
(self.D / Q) * self.S +
(Q / 2) * H)
plt.plot(Q, total_costs, linewidth=2,
label=f'${unit_price}/unit (Q ≥ {break_qty})')
# Mark optimal point
optimal = result['optimal']
plt.plot(optimal['feasible_quantity'], optimal['total_cost'],
'r*', markersize=20, label=f"Optimal: Q={optimal['feasible_quantity']:.0f}")
# Mark all candidates
for _, row in result['all_candidates'].iterrows():
plt.plot(row['feasible_quantity'], row['total_cost'],
'ko', markersize=8)
plt.xlabel('Order Quantity (Q)', fontsize=12)
plt.ylabel('Total Annual Cost ($)', fontsize=12)
plt.title('EOQ with Quantity Discounts', fontsize=14, fontweight='bold')
plt.legend(fontsize=10)
plt.grid(True, alpha=0.3)
plt.tight_layout()
return plt
# Example: Quantity Discounts
def example_quantity_discounts():
"""Example: EOQ with all-units quantity discount"""
price_breaks = [
(0, 50.00), # 0-499 units: $50/unit
(500, 48.00), # 500-999 units: $48/unit
(1000, 45.00), # 1000+ units: $45/unit
(2500, 43.00) # 2500+ units: $43/unit
]
model = EOQWithQuantityDiscounts(
annual_demand=10000,
ordering_cost=100,
holding_cost_rate=0.25,
price_breaks=price_breaks
)
result = model.calculate_optimal_quantity()
print("\n" + "=" * 70)
print("EOQ WITH QUANTITY DISCOUNTS ANALYSIS")
print("=" * 70)
print("\nPrice Break Structure:")
for qty, price in price_breaks:
print(f" {qty:>6} units or more: ${price:.2f}/unit")
print("\n\nCandidate Solutions:")
print(result['all_candidates'].to_string(index=False))
print("\n\n" + "=" * 70)
print("OPTIMAL SOLUTION")
print("=" * 70)
opt = result['optimal']
print(f"{'Order Quantity:':<30} {opt['feasible_quantity']:>15,.0f} units")
print(f"{'Unit Price:':<30} ${opt['unit_price']:>15,.2f}")
print(f"{'Orders per Year:':<30} {opt['orders_per_year']:>15,.2f}")
print(f"\n{'Cost Breakdown:':<30}")
print(f" {'Purchase Cost:':<28} ${opt['purchase_cost']:>15,.2f}")
print(f" {'Ordering Cost:':<28} ${opt['ordering_cost']:>15,.2f}")
print(f" {'Holding Cost:':<28} ${opt['holding_cost']:>15,.2f}")
print(f" {'-'*45}")
print(f" {'Total Annual Cost:':<28} ${opt['total_cost']:>15,.2f}")
print(f"\n{'Savings vs No Discount:':<30} {result['savings_vs_no_discount']:>15,.1f}%")
# Plot
model.plot_discount_analysis()
plt.savefig('/tmp/eoq_quantity_discounts.png', dpi=300, bbox_inches='tight')
print(f"\nDiscount analysis plot saved to /tmp/eoq_quantity_discounts.png")
return model, result
if __name__ == "__main__":
example_quantity_discounts()
经济生产批量(EPQ)
生产环境的 EPQ 模型
当商品由内部生产而非采购时,补货是逐步进行的(而非即时完成)。这就是经济生产批量(EPQ)模型。
假设:
- 生产率(p)> 需求率(d)
- 生产以恒定速率 p 件/天进行
- 不允许缺货
- 每次生产批次都会产生准备成本 S
与 EOQ 的主要区别: 生产期间库存逐步增加。
class EPQModel:
"""
Economic Production Quantity (EPQ) Model
Optimal production lot size when items are produced internally
at a finite production rate
"""
def __init__(self, annual_demand: float, production_rate: float,
unit_cost: float, setup_cost: float, holding_cost_rate: float):
"""
Parameters:
-----------
annual_demand : float
Annual demand rate (units/year)
production_rate : float
Production rate (units/year)
unit_cost : float
Cost per unit ($)
setup_cost : float
Fixed cost per production run ($)
holding_cost_rate : float
Annual holding cost as fraction of unit cost
"""
self.D = annual_demand
self.p = production_rate
self.C = unit_cost
self.S = setup_cost
self.r = holding_cost_rate
self.H = unit_cost * holding_cost_rate
# Validate: production rate must exceed demand rate
if self.p <= self.D:
raise ValueError("Production rate must exceed demand rate")
def calculate_epq(self) -> Dict:
"""
Calculate Economic Production Quantity
EPQ Formula:
EPQ = √(2DS/H) × √(p/(p-D))
The term √(p/(p-D)) adjusts for gradual replenishment
"""
# EPQ formula
epq = np.sqrt((2 * self.D * self.S) / self.H) * np.sqrt(self.p / (self.p - self.D))
# Production runs per year
runs_per_year = self.D / epq
# Production time per run (days)
production_time = (epq / self.p) * 365
# Maximum inventory level
# Inventory builds while producing and consuming
max_inventory = epq * (1 - self.D / self.p)
# Average inventory (half of maximum)
avg_inventory = max_inventory / 2
# Cost components
production_cost = self.D * self.C
setup_cost_total = runs_per_year * self.S
holding_cost_total = avg_inventory * self.H
total_cost = production_cost + setup_cost_total + holding_cost_total
# Cycle time
cycle_time_days = 365 / runs_per_year
return {
'epq': round(epq, 2),
'runs_per_year': round(runs_per_year, 2),
'production_time_days': round(production_time, 2),
'cycle_time_days': round(cycle_time_days, 2),
'max_inventory': round(max_inventory, 2),
'avg_inventory': round(avg_inventory, 2),
'total_annual_cost': round(total_cost, 2),
'production_cost': round(production_cost, 2),
'setup_cost': round(setup_cost_total, 2),
'holding_cost': round(holding_cost_total, 2),
'utilization': round((self.D / self.p) * 100, 2)
}
def simulate_inventory_cycle(self, num_cycles: int = 3) -> pd.DataFrame:
"""
Simulate inventory levels over multiple production cycles
Returns DataFrame with daily inventory levels
"""
result = self.calculate_epq()
epq = result['epq']
cycle_time = result['cycle_time_days']
production_time = result['production_time_days']
daily_demand = self.D / 365
daily_production = self.p / 365
inventory_data = []
current_inventory = 0
total_days = int(cycle_time * num_cycles)
for day in range(total_days):
cycle_day = day % cycle_time
# During production phase
if cycle_day < production_time:
# Inventory increases by net production
current_inventory += (daily_production - daily_demand)
else:
# Only consuming inventory
current_inventory -= daily_demand
# Prevent negative inventory (numerical precision)
current_inventory = max(0, current_inventory)
inventory_data.append({
'day': day,
'cycle': day // cycle_time + 1,
'cycle_day': cycle_day,
'inventory': current_inventory,
'producing': cycle_day < production_time
})
return pd.DataFrame(inventory_data)
def plot_inventory_cycle(self, num_cycles: int = 3):
"""Visualize inventory levels over production cycles"""
df = self.simulate_inventory_cycle(num_cycles)
result = self.calculate_epq()
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10))
# Plot 1: Inventory over time
production_periods = df[df['producing'] == True]
consumption_periods = df[df['producing'] == False]
ax1.plot(df['day'], df['inventory'], linewidth=2, color='blue')
ax1.fill_between(production_periods['day'], 0, production_periods['inventory'],
alpha=0.3, color='green', label='Production Phase')
ax1.axhline(y=result['avg_inventory'], color='red', linestyle='--',
linewidth=2, label=f"Avg Inventory = {result['avg_inventory']:.0f}")
ax1.axhline(y=result['max_inventory'], color='orange', linestyle='--',
linewidth=2, label=f"Max Inventory = {result['max_inventory']:.0f}")
ax1.set_xlabel('Day', fontsize=12)
ax1.set_ylabel('Inventory Level (units)', fontsize=12)
ax1.set_title('EPQ Inventory Cycle', fontsize=14, fontweight='bold')
ax1.legend(fontsize=10)
ax1.grid(True, alpha=0.3)
# Plot 2: Single cycle detail
single_cycle = df[df['cycle'] == 1]
ax2.plot(single_cycle['cycle_day'], single_cycle['inventory'],
linewidth=3, color='blue', marker='o', markersize=4)
ax2.axvline(x=result['production_time_days'], color='red',
linestyle='--', linewidth=2,
label=f"Production Time = {result['production_time_days']:.1f} days")
ax2.fill_between(single_cycle['cycle_day'], 0, single_cycle['inventory'],
where=single_cycle['producing'], alpha=0.3, color='green',
label='Producing')
ax2.fill_between(single_cycle['cycle_day'], 0, single_cycle['inventory'],
where=~single_cycle['producing'], alpha=0.3, color='red',
label='Only Consuming')
ax2.set_xlabel('Days in Cycle', fontsize=12)
ax2.set_ylabel('Inventory Level (units)', fontsize=12)
ax2.set_title('Single Production Cycle Detail', fontsize=14, fontweight='bold')
ax2.legend(fontsize=10)
ax2.grid(True, alpha=0.3)
plt.tight_layout()
return plt
# Example: EPQ
def example_epq():
"""Example: Economic Production Quantity calculation"""
model = EPQModel(
annual_demand=10000, # 10,000 units/year demand
production_rate=50000, # Can produce 50,000 units/year
unit_cost=50, # $50 per unit
setup_cost=500, # $500 setup cost per run
holding_cost_rate=0.25 # 25% holding cost
)
result = model.calculate_epq()
print("\n" + "=" * 70)
print("ECONOMIC PRODUCTION QUANTITY (EPQ) ANALYSIS")
print("=" * 70)
print(f"\nInput Parameters:")
print(f" Annual Demand: {model.D:,} units/year")
print(f" Production Rate: {model.p:,} units/year")
print(f" Unit Cost: ${model.C:.2f}")
print(f" Setup Cost: ${model.S:.2f}")
print(f" Holding Cost Rate: {model.r:.1%}")
print(f"\n{'Optimal Production Quantity (EPQ):':<40} {result['epq']:>15,.0f} units")
print(f"{'Production Runs per Year:':<40} {result['runs_per_year']:>15,.2f} runs")
print(f"{'Days Between Production Runs:':<40} {result['cycle_time_days']:>15,.1f} days")
print(f"{'Production Time per Run:':<40} {result['production_time_days']:>15,.1f} days")
print(f"\n{'Inventory Levels:':<40}")
print(f" {'Maximum Inventory:':<38} {result['max_inventory']:>15,.0f} units")
print(f" {'Average Inventory:':<38} {result['avg_inventory']:>15,.0f} units")
print(f"\n{'Cost Breakdown:':<40}")
print(f" {'Production Cost:':<38} ${result['production_cost']:>15,.2f}")
print(f" {'Setup Cost:':<38} ${result['setup_cost']:>15,.2f}")
print(f" {'Holding Cost:':<38} ${result['holding_cost']:>15,.2f}")
print(f" {'-'*55}")
print(f" {'Total Annual Cost:':<38} ${result['total_annual_cost']:>15,.2f}")
print(f"\n{'Production Capacity Utilization:':<40} {result['utilization']:>15,.1f}%")
# Plot
model.plot_inventory_cycle(num_cycles=3)
plt.savefig('/tmp/epq_inventory_cycle.png', dpi=300, bbox_inches='tight')
print(f"\nInventory cycle plot saved to /tmp/epq_inventory_cycle.png")
return model, result
if __name__ == "__main__":
example_epq()
允许计划欠交的 EOQ
欠交模型
如果允许欠交且会产生惩罚成本,那么有计划地允许缺货可能更经济。
决策变量:
- Q = 订货量
- S = 最大库存水平(< Q)
- Q - S = 计划欠交量
class EOQWithBackorders:
"""
EOQ model with planned backorders
Allows intentional stockouts when backorder cost is lower than holding cost
"""
def __init__(self, annual_demand: float, unit_cost: float,
ordering_cost: float, holding_cost_rate: float,
backorder_cost_rate: float):
"""
Parameters:
-----------
annual_demand : float
Annual demand in units
unit_cost : float
Cost per unit ($)
ordering_cost : float
Fixed cost per order ($)
holding_cost_rate : float
Annual holding cost as fraction of unit cost
backorder_cost_rate : float
Annual backorder cost per unit ($/unit/year)
"""
self.D = annual_demand
self.C = unit_cost
self.S_cost = ordering_cost
self.H = unit_cost * holding_cost_rate
self.B = backorder_cost_rate
def calculate_optimal(self) -> Dict:
"""
Calculate optimal order quantity and maximum inventory level
Formulas:
Q* = √(2DS/H) × √((H+B)/B)
S* = Q* × (B/(H+B))
Where S* is the maximum inventory level (peak before stockout)
"""
# Basic EOQ (without backorders)
eoq_basic = np.sqrt((2 * self.D * self.S_cost) / self.H)
# Optimal order quantity with backorders
Q_opt = eoq_basic * np.sqrt((self.H + self.B) / self.B)
# Maximum inventory level
S_opt = Q_opt * (self.B / (self.H + self.B))
# Maximum backorder level
backorder_max = Q_opt - S_opt
# Time fractions
time_with_inventory = S_opt / self.D # years
time_with_backorders = backorder_max / self.D # years
cycle_time = Q_opt / self.D # years
# Cost calculation
orders_per_year = self.D / Q_opt
ordering_cost = orders_per_year * self.S_cost
# Average inventory (triangular distribution)
avg_inventory = (S_opt ** 2) / (2 * Q_opt)
holding_cost = avg_inventory * self.H
# Average backorders
avg_backorders = (backorder_max ** 2) / (2 * Q_opt)
backorder_cost_total = avg_backorders * self.B
purchase_cost = self.D * self.C
total_cost = purchase_cost + ordering_cost + holding_cost + backorder_cost_total
# Compare with basic EOQ (no backorders)
cost_basic = (purchase_cost +
(self.D / eoq_basic) * self.S_cost +
(eoq_basic / 2) * self.H)
savings = cost_basic - total_cost
savings_pct = (savings / cost_basic) * 100
return {
'order_quantity': round(Q_opt, 2),
'max_inventory': round(S_opt, 2),
'max_backorders': round(backorder_max, 2),
'orders_per_year': round(orders_per_year, 2),
'cycle_time_days': round(cycle_time * 365, 2),
'time_with_inventory_days': round(time_with_inventory * 365, 2),
'time_with_backorders_days': round(time_with_backorders * 365, 2),
'avg_inventory': round(avg_inventory, 2),
'avg_backorders': round(avg_backorders, 2),
'total_cost': round(total_cost, 2),
'ordering_cost': round(ordering_cost, 2),
'holding_cost': round(holding_cost, 2),
'backorder_cost': round(backorder_cost_total, 2),
'cost_without_backorders': round(cost_basic, 2),
'savings': round(savings, 2),
'savings_pct': round(savings_pct, 2)
}
def plot_backorder_cycle(self):
"""Visualize inventory cycle with planned backorders"""
result = self.calculate_optimal()
Q = result['order_quantity']
S = result['max_inventory']
# Time points (in fractions of year)
t1 = S / self.D # Time to deplete inventory
t2 = Q / self.D # Full cycle time
# Convert to days for plotting
t1_days = t1 * 365
t2_days = t2 * 365
# Create time array for one cycle
t_positive = np.linspace(0, t1_days, 100)
t_negative = np.linspace(t1_days, t2_days, 100)
# Inventory levels
inv_positive = S - (self.D / 365) * t_positive
inv_negative = -(self.D / 365) * (t_negative - t1_days)
plt.figure(figsize=(14, 8))
# Plot inventory (positive)
plt.fill_between(t_positive, 0, inv_positive, alpha=0.3, color='green',
label='On-hand Inventory')
plt.plot(t_positive, inv_positive, linewidth=3, color='green')
# Plot backorders (negative)
plt.fill_between(t_negative, 0, inv_negative, alpha=0.3, color='red',
label='Backorders')
plt.plot(t_negative, inv_negative, linewidth=3, color='red')
# Mark key points
plt.plot(0, S, 'go', markersize=12, label=f'Order Arrives: +{S:.0f} units')
plt.plot(t1_days, 0, 'yo', markersize=12, label='Stockout Begins')
plt.plot(t2_days, -result['max_backorders'], 'ro', markersize=12,
label=f"Max Backorder: {result['max_backorders']:.0f} units")
# Add average lines
plt.axhline(y=result['avg_inventory'], color='green', linestyle='--',
linewidth=2, alpha=0.7, label=f"Avg Inventory: {result['avg_inventory']:.1f}")
plt.axhline(y=-result['avg_backorders'], color='red', linestyle='--',
linewidth=2, alpha=0.7, label=f"Avg Backorders: {result['avg_backorders']:.1f}")
plt.axhline(y=0, color='black', linewidth=1)
plt.axvline(x=t1_days, color='gray', linestyle=':', linewidth=2)
plt.xlabel('Days', fontsize=12)
plt.ylabel('Inventory Level (units)', fontsize=12)
plt.title('EOQ with Planned Backorders - Inventory Cycle',
fontsize=14, fontweight='bold')
plt.legend(fontsize=10, loc='upper right')
plt.grid(True, alpha=0.3)
plt.tight_layout()
return plt
# Example: Backorders
def example_backorders():
"""Example: EOQ with planned backorders"""
model = EOQWithBackorders(
annual_demand=10000,
unit_cost=50,
ordering_cost=100,
holding_cost_rate=0.25,
backorder_cost_rate=5.0 # $5 per unit per year backordered
)
result = model.calculate_optimal()
print("\n" + "=" * 70)
print("EOQ WITH PLANNED BACKORDERS ANALYSIS")
print("=" * 70)
print(f"\nInput Parameters:")
print(f" Annual Demand: {model.D:,} units")
print(f" Holding Cost: ${model.H:.2f}/unit/year")
print(f" Backorder Cost: ${model.B:.2f}/unit/year")
print(f" Ordering Cost: ${model.S_cost:.2f}/order")
print(f"\n{'Optimal Policy:':<45}")
print(f" {'Order Quantity:':<43} {result['order_quantity']:>15,.0f} units")
print(f" {'Maximum Inventory (before stockout):':<43} {result['max_inventory']:>15,.0f} units")
print(f" {'Maximum Backorders:':<43} {result['max_backorders']:>15,.0f} units")
print(f"\n{'Cycle Characteristics:':<45}")
print(f" {'Orders per Year:':<43} {result['orders_per_year']:>15,.2f}")
print(f" {'Cycle Time:':<43} {result['cycle_time_days']:>15,.1f} days")
print(f" {'Time with Inventory:':<43} {result['time_with_inventory_days']:>15,.1f} days")
print(f" {'Time with Backorders:':<43} {result['time_with_backorders_days']:>15,.1f} days")
print(f"\n{'Average Levels:':<45}")
print(f" {'Average Inventory:':<43} {result['avg_inventory']:>15,.1f} units")
print(f" {'Average Backorders:':<43} {result['avg_backorders']:>15,.1f} units")
print(f"\n{'Cost Analysis:':<45}")
print(f" {'Ordering Cost:':<43} ${result['ordering_cost']:>15,.2f}")
print(f" {'Holding Cost:':<43} ${result['holding_cost']:>15,.2f}")
print(f" {'Backorder Cost:':<43} ${result['backorder_cost']:>15,.2f}")
print(f" {'-'*60}")
print(f" {'Total Annual Cost (with backorders):':<43} ${result['total_cost']:>15,.2f}")
print(f" {'Total Cost without Backorders:':<43} ${result['cost_without_backorders']:>15,.2f}")
print(f" {'-'*60}")
print(f" {'Annual Savings:':<43} ${result['savings']:>15,.2f}")
print(f" {'Savings Percentage:':<43} {result['savings_pct']:>15,.1f}%")
# Plot
model.plot_backorder_cycle()
plt.savefig('/tmp/eoq_backorders.png', dpi=300, bbox_inches='tight')
print(f"\nBackorder cycle plot saved to /tmp/eoq_backorders.png")
return model, result
if __name__ == "__main__":
example_backorders()
工具和库
Python 库
数值与科学计算:
numpy:数组运算、数学函数scipy:优化、统计分布pandas:数据处理与分析
优化:
pulp:用于受约束 EOQ 的线性规划scipy.optimize:非线性优化
可视化:
matplotlib:绘制成本曲线、库存周期seaborn:统计可视化plotly:交互式仪表板
商业软件
采用 EOQ 的 ERP 系统:
- SAP:采用批量规模设定的物料需求计划(MRP)
- Oracle EBS:采用 EOQ 规则的订单管理
- Microsoft Dynamics:库存优化模块
- NetSuite:采用 EOQ 的需求计划
专业库存规划:
- Blue Yonder:高级库存优化
- Logility:批量规模设定和补货规划
- Kinaxis RapidResponse:库存策略优化
- Llamasoft:结合库存分析的供应链设计
常见挑战与解决方案
挑战:需求不确定
问题:
- 经典 EOQ 假设需求恒定且已知
- 实际需求会变化且不确定
解决方案:
- 在 EOQ 计算中使用预期需求 D
- 针对需求波动增加安全库存(参见 stochastic-inventory-models)
- 定期重新计算 EOQ(每月/每季度)
- 考虑采用需求情景进行稳健优化
- 使用 (Q, r) 连续检查策略,并设置再订货点
挑战:持有成本未知
问题:
- 难以估算实际持有成本率
- 持有成本包括资金、仓储、过时和损坏成本
解决方案:
- 采用行业基准(通常为单位成本的 15-35%)
- 将加权平均资本成本(WACC)作为最低值
- 加上仓储成本($/sq ft × 单位占用空间)
- 纳入保险和过时成本
- 对持有成本率进行敏感性分析
- 以 25% 作为合理的默认值
挑战:数量限制
问题:
- 供应商的 MOQ 可能与 EOQ 不同
- 包装限制(订购量必须是指定数量的倍数)
- 仓储容量有限
解决方案:
- 如果 MOQ > EOQ:按 MOQ 订购(接受更高的持有成本)
- 如果 MOQ < EOQ:将 EOQ 四舍五入至包装规格
- 对于容量限制:采用约束优化
- 与供应商协商降低 MOQ
- 考虑采用具有共享容量约束的多品项 EOQ
挑战:预算受限的多品项
问题:
- 每个 SKU 的 EOQ 都是独立计算的
- 总预算或仓储容量有限
解决方案:
- 建立约束优化问题
- 使用拉格朗日乘数进行连续松弛
- 实施 ABC 分析——重点关注 A 类品项
- 使用动态规划求精确解
- 考虑根据 EOQ 比例进行按比例分配
from scipy.optimize import minimize
def multi_item_eoq_constrained(items: List[Dict], budget: float) -> Dict:
"""
Multi-item EOQ with budget constraint
items: list of dicts with keys: demand, ordering_cost, holding_cost
budget: total budget for average inventory investment
"""
n = len(items)
def objective(Q):
"""Minimize total relevant cost"""
total_cost = 0
for i, q in enumerate(Q):
item = items[i]
ordering_cost = (item['demand'] / q) * item['ordering_cost']
holding_cost = (q / 2) * item['holding_cost']
total_cost += ordering_cost + holding_cost
return total_cost
def budget_constraint(Q):
"""Average inventory value <= budget"""
total_inventory_value = 0
for i, q in enumerate(Q):
avg_inventory = q / 2
total_inventory_value += avg_inventory * items[i]['unit_cost']
return budget - total_inventory_value
# Initial guess: unconstrained EOQ
Q0 = [np.sqrt(2 * item['demand'] * item['ordering_cost'] / item['holding_cost'])
for item in items]
# Constraints
cons = [{'type': 'ineq', 'fun': budget_constraint}]
# Bounds: positive quantities
bounds = [(1, None) for _ in range(n)]
# Solve
result = minimize(objective, Q0, method='SLSQP', bounds=bounds, constraints=cons)
return {
'optimal_quantities': result.x,
'total_cost': result.fun,
'success': result.success
}
挑战:参数随时间变化
问题:
- 需求、成本或价格会随时间变化
- 存在季节性需求模式
- 通货膨胀
解决方案:
- 定期重新计算 EOQ(每月/每季度)
- 使用滚动平均需求作为 D
- 考虑有限周期批量规模模型(参见 dynamic-lot-sizing)
- 根据预测更新实施动态 EOQ
- 对于季节性需求:分别计算各季节的 EOQ
挑战:交货提前期不确定
问题:
- EOQ 未考虑交货提前期的波动
- 补货期间存在缺货风险
解决方案:
- EOQ 用于确定 Q,另由再订货点 (r) 处理交货提前期
- 针对交货提前期波动计算安全库存
- 使用 (Q, r) 策略:库存达到 r 时订购 Q
- 安全库存计算参见 stochastic-inventory-models
- 监控供应商绩效并调整交货提前期估算
输出格式
EOQ 分析报告
执行摘要:
- 当前订货做法:每 73 天订购 2,000 件
- 建议的 EOQ:每 46 天订购 1,265 件
- 年度节省:$3,450(成本降低 12%)
- 实施方式:调整订货量和订货频率
输入参数:
| 参数 | 数值 |
|---|---|
| 年需求量 | 10,000 件 |
| 单位成本 | $50.00 |
| 订货成本 | $100/次订货 |
| 库存持有成本率 | 25%/年 |
| 库存持有成本 | $12.50/件/年 |
最优策略:
| 指标 | 当前 | 最优(EOQ) | 改善幅度 |
|---|---|---|---|
| 订货量 | 2,000 | 1,265 | 不适用 |
| 年订货次数 | 5 | 7.9 | +58% |
| 订货间隔天数 | 73 | 46 | -37% |
| 平均库存 | 1,000 | 633 | -37% |
| 年总成本 | $531,250 | $527,800 | -$3,450 (-0.65%) |
成本明细:
| 成本项目 | 当前 | 最优(EOQ) | 差额 |
|---|---|---|---|
| 采购成本 | $500,000 | $500,000 | $0 |
| 订货成本 | $500 | $790 | +$290 |
| 库存持有成本 | $12,500 | $7,910 | -$4,590 |
| 总计 | $513,000 | $508,700 | -$4,300 |
再订货点:
- 交货期:14 天
- 平均日需求量:27.4 件
- 交货期需求量:384 件
- 建议再订货点:385 件(安全库存为零)
实施建议:
- 将订货量调整为 1,265 件(如包装限制,可取整为 1,250 件)
- 将再订货点设为 385 件
- 监控库存,并在达到再订货点时下单
- 每季度审查并重新计算 EOQ
- 考虑需求波动,以调整安全库存
需要询问的问题
如果需要更多背景信息:
- 该商品的年需求量是多少?(件/年)
- 单位成本是多少?
- 下单成本是多少?(每次订货的固定成本)
- 库存持有成本率是多少?(每年占单位成本的百分比)
- 供应商是否提供数量折扣?
- 该商品是外购还是内部生产?
- 如果是内部生产:生产速率是多少?
- 是否可以接受缺货?如果可以,欠交罚金是多少?
- 从下单到交货需要多长时间?
- 是否存在任何限制?(MOQ、仓储容量、预算限制)
相关技能
- inventory-optimization:全面的库存管理框架
- lot-sizing-problems:多周期批量订货模型
- dynamic-lot-sizing:时变需求批量订货
- stochastic-inventory-models:考虑不确定性的概率库存模型
- newsvendor-problem:单周期库存决策
- multi-echelon-inventory:全网络库存优化
- demand-forecasting:用于 EOQ 输入的需求估算
- supply-chain-analytics:绩效指标和 KPI
评论
支持 Markdown