How to Calculate Re-Slotting ROI and Break-Even
A velocity recompute just flagged a SKU for a better slot. Before you queue the move, you need one number: does the travel time it saves ever repay the labor it costs to relocate? Most re-slotting waste comes from executing moves that look good on a tier map but never earn back their relocation labor — a SKU that saves four seconds a pick but only ships twice a week will not pay for its own move inside a fiscal year. This page models a proposed move as one-time cost versus daily savings, computes payback days and net ROI over a horizon, and gates emission on a payback threshold. It is the economics step of the Threshold Optimization for Re-slotting guide, under the Location Assignment & ABC Classification Algorithms architecture.
Prerequisites
- Python 3.10+ — the model uses
dataclassrecords, type hints, and structuredlogging. - A per-pick travel saving —
seconds_saved_per_pickfor the proposed move, taken from your distance model. Do not estimate it by eye; source it from Travel-Distance & Pick-Path Cost Modeling, which turns a from-slot/to-slot pair into a real time delta. - A demand rate —
picks_per_dayfor the SKU over the same window that produced its velocity tier, so the savings rate matches the classification that triggered the move. - A move-cost estimate — the fully-loaded labor to physically relocate one SKU (pull, transport, put-away, re-label, WMS update).
- A payback policy — the maximum number of days a move is allowed to take to repay itself, set by finance, not by the algorithm.
Configuration Block
Four levers govern every decision. payback_threshold_days is the gate; horizon_days sizes the net-ROI window; the two cost inputs convert time into dollars.
# reslot_roi.yaml — one profile per facility
reslot_roi:
move_cost_per_slot: 18.0 # fully-loaded labor $ to relocate one SKU
labor_rate: 32.0 # picker cost $/hour, used to price saved travel time
horizon_days: 180 # planning window for net ROI
payback_threshold_days: 45 # emit the move only if it pays back within this many days
# Equivalent Python config dict consumed by the model
RESLOT_ROI = {
"move_cost_per_slot": 18.0,
"labor_rate": 32.0,
"horizon_days": 180,
"payback_threshold_days": 45,
}
Implementation
One function prices the move. It converts the per-pick time saving into a daily dollar saving, divides the move cost by that rate to get payback days, projects net ROI over the horizon, and approves the move only when it repays inside the threshold.
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
logger = logging.getLogger("reslot.roi")
@dataclass(frozen=True)
class MoveProposal:
"""A candidate relocation and the travel saving it yields per pick."""
sku_id: str
from_slot: str
to_slot: str
picks_per_day: float
seconds_saved_per_pick: float # from the travel-distance model; may be negative
@dataclass(frozen=True)
class ReslotEconomics:
sku_id: str
move_cost: float
daily_savings: float
break_even_days: float # inf when the move never pays back
net_roi: float # net gain over horizon / move cost
approved: bool
def evaluate_reslot(proposal: MoveProposal, cfg: dict) -> ReslotEconomics:
"""Price a proposed move: payback days, horizon ROI, and an emit/skip flag."""
# Saved picker-hours per day, valued at the labor rate.
hours_saved = (proposal.seconds_saved_per_pick * proposal.picks_per_day) / 3600.0
daily_savings = hours_saved * cfg["labor_rate"]
move_cost = cfg["move_cost_per_slot"]
if daily_savings <= 0:
logger.info("skip %s: non-positive daily savings (%.2f/day)",
proposal.sku_id, daily_savings)
return ReslotEconomics(proposal.sku_id, move_cost, daily_savings,
math.inf, -1.0, approved=False)
break_even_days = move_cost / daily_savings
horizon_savings = daily_savings * cfg["horizon_days"]
net_roi = (horizon_savings - move_cost) / move_cost
approved = break_even_days <= cfg["payback_threshold_days"]
logger.info(
"%s: save $%.2f/day, break-even %.1f d, ROI %.0f%% over %dd -> %s",
proposal.sku_id, daily_savings, break_even_days, net_roi * 100,
cfg["horizon_days"], "EMIT" if approved else "skip",
)
return ReslotEconomics(proposal.sku_id, move_cost, daily_savings,
break_even_days, net_roi, approved)
Step-by-Step Walkthrough
- Value the saved time.
seconds_saved_per_pick * picks_per_dayis the daily travel time the move removes; dividing by 3600 converts to hours and multiplying bylabor_rateprices it in dollars. Both inputs come from the same demand window, so a fast SKU with a big travel delta produces a largedaily_savingsand a slow one produces almost nothing. - Reject non-earning moves early. If
daily_savingsis zero or negative — the “better” slot is not actually closer for this SKU’s picks, or demand is negligible — the function returnsbreak_even_days = infandapproved = Falsebefore any division. This is the guard that stops lateral moves that cost labor and save nothing. - Divide cost by rate for payback.
break_even_days = move_cost / daily_savingsis the crossover day in the figure: the point where accumulated savings equal the one-timemove_cost_per_slot. - Project net ROI over the horizon.
horizon_savingsis the daily rate carried acrosshorizon_days;net_roiexpresses the gain net of the move cost as a multiple of that cost. A move that pays back on day 45 of a 180-day horizon returns roughly 3x its cost. - Gate on the threshold.
approvedis true only whenbreak_even_days <= payback_threshold_days. Tightening the threshold makes the queue more selective; loosening it admits slower-paying moves. This is the single knob you hand to finance.
Verification
Drive one clearly-good move and one clearly-bad move through the model and assert the gate behaves. The fast SKU pays back well inside the threshold; the slow one never earns its move.
import logging
logging.basicConfig(level=logging.INFO)
fast = MoveProposal("SKU-A1", from_slot="R12-3", to_slot="R01-1",
picks_per_day=40.0, seconds_saved_per_pick=9.0)
slow = MoveProposal("SKU-Z9", from_slot="R44-2", to_slot="R40-1",
picks_per_day=5.0, seconds_saved_per_pick=4.5)
good = evaluate_reslot(fast, RESLOT_ROI)
bad = evaluate_reslot(slow, RESLOT_ROI)
# Fast mover: high daily savings -> pays back fast -> emitted.
assert good.approved
assert good.break_even_days < RESLOT_ROI["payback_threshold_days"]
# Slow mover: trickle of savings -> payback beyond threshold -> skipped.
assert not bad.approved
print(f"{good.sku_id}: break-even {good.break_even_days:.1f}d, ROI {good.net_roi:.0%}")
print(f"{bad.sku_id}: break-even {bad.break_even_days:.1f}d -> skipped")
Sample expected output:
INFO:reslot.roi:SKU-A1: save $3.20/day, break-even 5.6 d, ROI 3100% over 180d -> EMIT
INFO:reslot.roi:SKU-Z9: save $0.20/day, break-even 90.0 d, ROI 100% over 180d -> skip
SKU-A1: break-even 5.6d, ROI 3100%
SKU-Z9: break-even 90.0d -> skipped
Note the trap the gate catches: SKU-Z9 still shows a positive 180-day ROI of 100%, yet its 90-day break-even exceeds the 45-day threshold, so it is correctly skipped. Horizon ROI alone would have waved it through; payback days is what keeps slow-earning moves out of the queue.
Common Pitfalls
- Double-counting shared savings. If two SKUs both claim the time saved on a shared aisle segment, summing their
daily_savingsbooks the same seconds twice. Attribute each travel saving to exactly one move, or re-derive savings from the travel model after the batch of moves is applied, not before. - Ignoring congestion. A move that saves distance can still lose time if it funnels more picks into an already-busy aisle. The
seconds_saved_per_pickfrom a static distance matrix is optimistic there; validate it against the congestion model before trusting the ROI. - Optimistic velocity.
picks_per_daytaken from a promotional spike inflates every downstream dollar. Use the same smoothed, de-spiked demand rate that produced the SKU’s tier, or a decaying move will look permanently profitable. - Pricing the move too cheaply.
move_cost_per_slotmust include put-away, re-labeling, and the WMS transaction, not just the walk. An under-priced move passes the gate and then quietly loses money on the floor.
Related
- Threshold Optimization for Re-slotting — the parent guide: dwell rules and thresholds that decide which flips become move candidates.
- How to Schedule Re-Slotting Windows to Minimize Disruption — once a move is approved here, this sibling schedules it into a low-activity window.
- Travel-Distance & Pick-Path Cost Modeling — the source of the per-pick time saving this ROI depends on.
- ABC Classification Tuning — the tier changes that generate the move candidates priced here.