Slotting Architecture · 9 min read

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.

Cumulative move cost versus cumulative travel savings crossing at the break-even day A chart with days since the move on the horizontal axis and cumulative dollars on the vertical axis. A dashed horizontal line marks the one-time cumulative move cost, incurred on day zero and constant thereafter. A solid rising line marks cumulative travel savings, starting at zero and increasing linearly with each day of picking. The two lines cross at the break-even day, near day 53, where accumulated savings equal the move cost. A vertical dashed line marks the payback threshold at day 90; because the break-even day falls to the left of the threshold, the move is approved. The shaded triangle between the savings and cost lines to the right of the crossover is the net gain over the planning horizon. Move cost vs. cumulative savings — break-even payback threshold (90 d) cumulative move cost (one-time) cumulative savings break-even net gain over horizon 0 ~53 d 180 d $0 $cost Days since move Cumulative $
The move cost is a one-time labor charge (flat line); travel savings accumulate a little every picking day (rising line). Break-even is where they cross. Emit the move only when that crossover falls left of your payback threshold — here ~53 days against a 90-day gate, so the move is approved and the shaded net gain is real.

Prerequisites

  • Python 3.10+ — the model uses dataclass records, type hints, and structured logging.
  • A per-pick travel savingseconds_saved_per_pick for 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 ratepicks_per_day for 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

  1. Value the saved time. seconds_saved_per_pick * picks_per_day is the daily travel time the move removes; dividing by 3600 converts to hours and multiplying by labor_rate prices it in dollars. Both inputs come from the same demand window, so a fast SKU with a big travel delta produces a large daily_savings and a slow one produces almost nothing.
  2. Reject non-earning moves early. If daily_savings is zero or negative — the “better” slot is not actually closer for this SKU’s picks, or demand is negligible — the function returns break_even_days = inf and approved = False before any division. This is the guard that stops lateral moves that cost labor and save nothing.
  3. Divide cost by rate for payback. break_even_days = move_cost / daily_savings is the crossover day in the figure: the point where accumulated savings equal the one-time move_cost_per_slot.
  4. Project net ROI over the horizon. horizon_savings is the daily rate carried across horizon_days; net_roi expresses 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.
  5. Gate on the threshold. approved is true only when break_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_savings books 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_pick from a static distance matrix is optimistic there; validate it against the congestion model before trusting the ROI.
  • Optimistic velocity. picks_per_day taken 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_slot must 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.