Reconciling Supplier Lead-Time Drift in Reorder Forecasts

A reorder forecast that trusts a supplier’s promised lead time instead of what actually happens at the receiving dock is a forecast built on a number nobody re-verifies until the parts fail to arrive. This page is part of the supplier lead-time synchronization work inside asset lookup and inventory synchronization: it reconciles the promised lead time a purchasing record stores against the actual latency recorded at goods receipt, so the reorder point that trips automated reorder triggers reflects reality instead of a vendor’s sales-order SLA. Skip this reconciliation and the forecast quietly drifts further behind every cycle, until a routine reorder arrives too late and blocks a work order.

Incident Profile

A facilities team reorders bearing seals from a supplier whose contract promises a 5-day lead time. The reorder point in the CMMS was sized against that 5-day promise months ago and has never been revisited. The following excerpt, stitched from the procurement sync log and the goods-receipt log for one purchase order, shows the promise and the outcome side by side.

2026-06-02 09:14:02 INFO  [procurement_sync] PO-55821 SKU=SEAL-2201 supplier=Acme Seals promised_lead_days=5 promised_eta=2026-06-07
2026-06-13 08:01:40 INFO  [goods_receipt] PO-55821 SKU=SEAL-2201 received_at=2026-06-13 actual_lead_days=11 variance_days=+6
2026-06-13 08:02:15 WARNING [reorder_engine] SKU=SEAL-2201 reorder_point=18.0 computed_from promised_lead_days=5 safety_stock=6.7
2026-06-13 08:02:15 ERROR   [reorder_engine] SKU=SEAL-2201 on_hand=0 reserved=4 stockout_detected work_order=WO-77410 blocked
2026-06-13 08:02:16 ERROR   [work_order_gate] WO-77410 asset=CONV-09 status=blocked_on_parts escalation_tier=2

The receiving log is unambiguous: the PO placed on June 2 promised delivery in 5 days, but the seal did not clear goods receipt until June 13 — 11 days later, a 6-day (120%) overrun against the promise. That single order is not an anomaly. Pulled across the last six purchase orders for the same SKU and supplier, actual lead time has ranged from 5 to 12 days while the promised figure in the purchasing record has stayed fixed at 5. The reorder point, however, was computed once against the promise and never recalculated against what goods receipt actually recorded, so it under-covered the gap by design. When on-hand stock hit zero with four units still reserved against open jobs, the reorder engine had no cushion left, and work order WO-77410 — a conveyor bearing job — sat blocked on parts until the next delivery cleared.

The distribution below shows why this particular failure was inevitable rather than unlucky: the promised lead time sits near the peak of the order history, but the right tail of actual deliveries stays fat instead of tapering off, and the 90th-percentile actual lead time lands more than double the promise.

Promised vs. actual lead-time distribution showing drift A histogram of seventy-two purchase orders for SKU SEAL-2201 grouped by actual lead time in days from three to fourteen. The distribution peaks at the five-day promised lead time but has a fat right tail: order counts stay high through eleven, twelve, and thirteen days instead of dropping toward zero. The five-day bin is highlighted as the promised lead time. The eleven-day bin is highlighted as the ninetieth percentile of actual lead time. A dashed vertical line marks the mean of six point two days, which sits far short of the tail and would understate the reorder point if used alone. A bracket below the axis marks a six-day drift between the promised and p90 lead times. Promised vs. actual lead time: where the forecast breaks Promised lead time: 5 days Mean: 6.2 days (hides the tail) p90 actual: 11 days 3d 4d 5d 6d 7d 8d 9d 10d 11d 12d 13d 14d Actual lead time from PO issue to goods receipt (days) Order count Drift: +6 days versus promised (120% over)

Root Cause Analysis

Nothing here is a data-entry error; every field involved holds a value someone believed was correct. Three gaps compound so that a stale promise keeps driving the reorder point long after it stops matching reality.

  1. The forecast trusts the static promised lead time and never re-checks it. promised_lead_days is captured once, at contract signing or PO creation, and treated as a constant thereafter. Nothing in the reorder pipeline asks whether the supplier has actually been meeting it.
  2. No reconciliation joins purchase-order dates to goods-receipt dates. The purchasing system knows when a PO was placed; the receiving system knows when the part physically arrived. As long as those two records never get joined and diffed, drift between promise and reality stays invisible — the reorder point that feeds inventory threshold optimization keeps assuming a lead time nobody has measured in months.
  3. Using the mean lead time hides a fat right tail. Even where teams do look at history, averaging actual lead times produces a deceptively tame number — across this SKU’s fuller order history (the distribution above), a mean of 6.2 days looks close enough to the 5-day promise to ignore. But the distribution is right-skewed: most orders arrive close to the mean, while a meaningful share arrive far later, and those late orders are exactly the ones that empty the shelf before the next delivery lands. A safety-stock calculation built on the mean covers the typical case and fails on the case that actually causes stockouts.

The result is a reorder point sized for a supplier relationship that no longer exists. The promise says 5 days; the operational reality this supplier has delivered against for months is closer to 11 days at the 90th percentile, and the forecast never learned the difference.

Resolution

The fix replaces a single constant with a reconciliation step: join every purchase order to its goods-receipt record, compute the actual lead time per order, and summarize that history with a high percentile instead of an average. The percentile — not the mean — becomes the effective lead time fed into the reorder point.

Before — reorder point computed from the promised constant only:

import math

PROMISED_LEAD_DAYS = {
    "SEAL-2201": 5,
}


def reorder_point(sku: str, avg_daily_demand: float, safety_factor: float = 1.5) -> float:
    # Trusts the supplier's promised lead time verbatim -- never checked
    # against what goods receipt actually recorded.
    lead_days = PROMISED_LEAD_DAYS[sku]
    safety_stock = safety_factor * math.sqrt(lead_days) * avg_daily_demand
    return lead_days * avg_daily_demand + safety_stock

After — reconcile actual receipts, use p90, flag drift, and feed the effective lead time forward:

import math
from datetime import date
from statistics import mean
from typing import List, NamedTuple


class ReceiptRecord(NamedTuple):
    sku: str
    supplier: str
    po_date: date
    receipt_date: date
    promised_lead_days: int


def actual_lead_days(record: ReceiptRecord) -> int:
    return (record.receipt_date - record.po_date).days


def percentile(values: List[float], pct: float) -> float:
    if not values:
        return 0.0
    ordered = sorted(values)
    k = (len(ordered) - 1) * pct
    f = math.floor(k)
    c = math.ceil(k)
    if f == c:
        return ordered[int(k)]
    return ordered[f] + (ordered[c] - ordered[f]) * (k - f)


def reconcile(records: List[ReceiptRecord], drift_threshold: float = 1.25) -> dict:
    """Join PO-to-receipt history for one supplier/SKU pair and compute the
    effective lead time from the 90th percentile, not the mean."""
    lead_times = [float(actual_lead_days(r)) for r in records]
    promised = records[0].promised_lead_days
    avg = mean(lead_times)
    p90 = percentile(lead_times, 0.90)
    # The mean looks reassuringly close to the promise while p90 exposes the
    # slow tail that actually causes stockouts, so p90 drives the effective lead time.
    drifted = p90 > promised * drift_threshold
    return {
        "sku": records[0].sku,
        "supplier": records[0].supplier,
        "promised_lead_days": promised,
        "mean_actual_lead_days": round(avg, 1),
        "p90_actual_lead_days": round(p90, 1),
        "drift_flag": drifted,
        "effective_lead_days": max(promised, round(p90, 1)),
    }


def reorder_point(effective_lead_days: float, avg_daily_demand: float, safety_factor: float = 1.5) -> float:
    safety_stock = safety_factor * math.sqrt(effective_lead_days) * avg_daily_demand
    return effective_lead_days * avg_daily_demand + safety_stock

Three changes carry the fix. reconcile joins the two records every reorder calculation previously kept apart — po_date and receipt_date — and computes actual_lead_days per order instead of accepting the promise as fact. The summary statistic changes from a mean to percentile(..., 0.90), so the number that reaches the reorder point represents the slow tail of deliveries rather than the typical case. And reorder_point now takes effective_lead_days as an explicit parameter — computed once, logged, and re-derived on a schedule — instead of pulling a hardcoded constant that nobody revisits.

Minimal Reproducible Pipeline

This script runs as-is with no external services. It reconciles a small purchase-order-to-receipt history for one SKU, computes the drift flag and effective lead time, and — if on-hand stock is short of the reconciled reorder point — emits a canonical WorkOrderPayload (with the site-wide SLA fields priority, requested_completion, and escalation_tier) carrying an escalated priority when drift was detected.

import logging
import math
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from enum import Enum
from statistics import mean
from typing import Dict, List, NamedTuple, Optional

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("lead_time_reconciler")


class Priority(str, Enum):
    CRITICAL = "critical"
    HIGH = "high"
    STANDARD = "standard"
    PLANNED = "planned"


@dataclass
class WorkOrderPayload:
    """Canonical CMMS work order -- SLA fields are mandatory site-wide."""
    work_order_id: str
    asset_id: str
    part_skus: List[str]
    required_quantities: Dict[str, int]
    location_id: str
    priority: Priority = Priority.STANDARD
    requested_completion: Optional[datetime] = None
    escalation_tier: int = 0
    status: str = "open"
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


class ReceiptRecord(NamedTuple):
    sku: str
    supplier: str
    po_date: date
    receipt_date: date
    promised_lead_days: int


def actual_lead_days(record: ReceiptRecord) -> int:
    return (record.receipt_date - record.po_date).days


def percentile(values: List[float], pct: float) -> float:
    if not values:
        return 0.0
    ordered = sorted(values)
    k = (len(ordered) - 1) * pct
    f = math.floor(k)
    c = math.ceil(k)
    if f == c:
        return ordered[int(k)]
    return ordered[f] + (ordered[c] - ordered[f]) * (k - f)


def reconcile(records: List[ReceiptRecord], drift_threshold: float = 1.25) -> dict:
    lead_times = [float(actual_lead_days(r)) for r in records]
    promised = records[0].promised_lead_days
    avg = mean(lead_times)
    p90 = percentile(lead_times, 0.90)
    drifted = p90 > promised * drift_threshold
    effective = max(promised, round(p90, 1))
    result = {
        "sku": records[0].sku,
        "supplier": records[0].supplier,
        "promised_lead_days": promised,
        "mean_actual_lead_days": round(avg, 1),
        "p90_actual_lead_days": round(p90, 1),
        "drift_flag": drifted,
        "effective_lead_days": effective,
    }
    logger.info(
        "%s@%s promised=%sd mean=%.1fd p90=%.1fd drift=%s effective=%.1fd",
        result["sku"], result["supplier"], promised, avg, p90, drifted, effective,
    )
    return result


def reorder_point(effective_lead_days: float, avg_daily_demand: float, safety_factor: float = 1.5) -> float:
    safety_stock = safety_factor * math.sqrt(effective_lead_days) * avg_daily_demand
    return effective_lead_days * avg_daily_demand + safety_stock


def raise_reorder_work_order(
    sku: str, reconciled: dict, on_hand: int, asset_id: str, location_id: str
) -> Optional[WorkOrderPayload]:
    """Compare on-hand stock to the reconciled reorder point and, if short,
    emit a work order that carries the drift flag through as an escalation."""
    rop = reorder_point(reconciled["effective_lead_days"], avg_daily_demand=2.0)
    if on_hand >= rop:
        logger.info("%s stock (%d) covers reorder point (%.1f) -- no action.", sku, on_hand, rop)
        return None

    escalation = 2 if reconciled["drift_flag"] else 1
    wo = WorkOrderPayload(
        work_order_id=f"WO-REORDER-{sku}",
        asset_id=asset_id,
        part_skus=[sku],
        required_quantities={sku: 1},
        location_id=location_id,
        priority=Priority.HIGH if reconciled["drift_flag"] else Priority.STANDARD,
        escalation_tier=escalation,
    )
    logger.warning(
        "Reorder triggered for %s: on_hand=%d < reorder_point=%.1f (drift=%s) -> %s",
        sku, on_hand, rop, reconciled["drift_flag"], wo.work_order_id,
    )
    return wo


if __name__ == "__main__":
    history = [
        ReceiptRecord("SEAL-2201", "Acme Seals", date(2026, 3, 1), date(2026, 3, 6), 5),
        ReceiptRecord("SEAL-2201", "Acme Seals", date(2026, 3, 20), date(2026, 3, 27), 5),
        ReceiptRecord("SEAL-2201", "Acme Seals", date(2026, 4, 10), date(2026, 4, 22), 5),
        ReceiptRecord("SEAL-2201", "Acme Seals", date(2026, 4, 28), date(2026, 5, 9), 5),
        ReceiptRecord("SEAL-2201", "Acme Seals", date(2026, 5, 15), date(2026, 5, 21), 5),
        ReceiptRecord("SEAL-2201", "Acme Seals", date(2026, 6, 2), date(2026, 6, 13), 5),
    ]
    reconciled = reconcile(history)
    work_order = raise_reorder_work_order(
        sku="SEAL-2201",
        reconciled=reconciled,
        on_hand=3,
        asset_id="CONV-09",
        location_id="WH-04",
    )
    if work_order:
        logger.info(
            "Emitted %s at priority=%s tier=%d",
            work_order.work_order_id, work_order.priority, work_order.escalation_tier,
        )

Run it as-is: the sample history reproduces the incident’s numbers (promised 5 days, p90 near 11), drift_flag comes back True, and with on_hand=3 the script emits WO-REORDER-SEAL-2201 at Priority.HIGH and escalation_tier=2 — the same escalation a blocked job would need once a parts availability check confirms the shelf is genuinely empty rather than just stale.

Prevention Checklist

FAQ

Why use the 90th percentile instead of the mean or the maximum lead time?

The mean is pulled toward the typical case and, on a right-skewed distribution like supplier lead time, sits close to the promise even when a meaningful share of deliveries arrive much later — exactly the deliveries that cause stockouts. The maximum overreacts to a single outlier (a customs delay, a supplier holiday) and inflates the reorder point permanently. The 90th percentile is a middle ground: it covers the slow tail that actually matters for stockout risk without being dictated by one extreme event.

We don’t have much receipt history yet — is p90 reliable on a small sample?

Treat any percentile computed from fewer than roughly 15-20 receipts as provisional. With a small sample, widen the safety margin (use max(promised, p90) * 1.1, for instance) until more orders accumulate, and re-run the reconciliation on a rolling window rather than a one-time snapshot. A drift flag from a thin sample should trigger a manual review, not an automatic reorder-point change.

How does this interact with automated reorder triggers and threshold optimization?

Reconciliation produces one number — the effective lead time — that both consumers need. Automated reorder triggers should call reorder_point() with the reconciled effective lead time rather than a stored constant, and inventory threshold optimization should treat a persistent drift flag as a signal to re-tune safety-stock factors for that supplier specifically, not for the SKU catalog as a whole.

Should we renegotiate the promised lead time instead of just widening the buffer?

Widening the buffer is the immediate fix because it stops stockouts today, but it also raises carrying cost for every unit held against a promise the supplier isn’t keeping. Once the drift flag has held true across several reconciliation runs, that data is the evidence to bring back to the supplier — either the promised lead time gets corrected to match reality, or the supplier fixes the fulfillment process that’s causing the drift.

This reconciliation feeds directly into automated reorder triggers and should be tuned alongside inventory threshold optimization; confirm a triggered reorder against a genuine shortage with parts availability checks, and see the broader read-and-reconcile flow in asset lookup and inventory synchronization.

Part of: Supplier Lead-Time Synchronization.