Supplier Lead-Time Synchronization for Reorder Timing
Supplier lead-time synchronization is the replenishment-latency stage of the Asset Lookup & Inventory Synchronization pipeline — the component that ingests each supplier’s promised delivery windows, measures the latency actually observed at goods-receipt, and publishes a reconciled effective lead time that downstream reorder logic can trust.
Reorder points are only as honest as the lead time behind them. A storeroom that assumes a five-day replenishment when the supplier is really running eleven will trip its safety stock, stall a critical repair, and blow an SLA that had nothing to do with the wrench. The failure is silent because the promised number sits in a contract nobody re-reads, while the real latency drifts month over month with freight, seasonality, and supplier capacity. This component closes that gap by treating lead time as a measured, continuously reconciled quantity rather than a static field. For facilities managers and reliability engineers it yields replenishment timing that matches ground truth; for Python automation and CMMS integration teams it demands disciplined unit normalization, a rolling statistical window over actual receipts, and explicit drift detection against the promise. This guide implements that stage end to end — prerequisites, the input/output data contract, a step-by-step build, a configuration reference, validation checks, and the failure modes you will actually hit in production.
Prerequisites
This component runs as a scheduled reconciliation job — typically nightly — that reads supplier feeds and goods-receipt history, recomputes an effective lead time per supplier and SKU, and writes it to a table the reorder engine reads. Before you deploy it, confirm the following are in place.
- Python 3.11+ with
pydantic>=2.6for feed validation,httpx>=0.27for async pulls against supplier APIs, andpandas>=2.2for the rolling statistical window over receipt history. No message broker is required; the job is invoked by cron or the same scheduler that drives inventory threshold optimization. - A supplier lead-time source. Either a REST/EDI feed exposing promised lead times per supplier and SKU (
GET /api/v1/suppliers/lead-times), a flat EDI 855/856 export, or a vendor portal CSV. It must carry a stablesupplier_idandsku, a promised duration, and the unit that duration is expressed in. - A goods-receipt history. The actual side of the reconciliation comes from purchase-order receipt events — an order-placed timestamp and a received timestamp per line — surfaced by the CMMS or ERP. Without observed receipts there is nothing to reconcile the promise against.
- A resolved part master. Lead-time math is only meaningful once external SKUs map to canonical CMMS parts and those parts are anchored to the equipment tree from asset hierarchy design. A supplier SKU that resolves to two different parts will corrupt both effective lead times.
- Environment variables:
SUPPLIER_FEED_URL,SUPPLIER_API_TOKEN(carrying theprocurement:readscope), andLEADTIME_WINDOW_DAYS(default90). A token missingprocurement:readmust fail closed rather than publishing a lead time computed from a partial feed.
Architecture and Data Contract
The job sits between the supplier’s promise and the reorder engine’s timing math. It never places an order and it never mutates the part master — it reads promised and actual latency, reconciles them, and writes an effective lead time. Four boundaries keep the computation honest and stop replenishment timing from silently trusting a stale contract.
- Ingest boundary: promised lead times enter only after schema validation; a feed row missing a unit or a
supplier_idis quarantined before it can skew a mean. - Normalization boundary: every duration is converted to a single canonical unit (hours) and every currency-of-time convention — business days, calendar days, weeks — is resolved against a supplier calendar. Mixed units never reach the statistics.
- Reconciliation boundary: a rolling window over actual goods-receipt latency yields a mean and a high percentile; the promised value is compared against that distribution to flag drift. Same receipts, same window, same result.
- Publish boundary: one effective lead time per supplier and SKU is written for the reorder engine — automated reorder triggers and threshold math consume it; this component never sizes an order itself.
The effective lead time is not simply the promised number, nor the raw mean of receipts. It is a defensible blend: the rolling actual percentile when enough receipts exist, the promise when history is too thin, and a drift flag whenever the two diverge beyond tolerance so a human can renegotiate the contract.
The contract across the reconciliation boundary is explicit: the inputs are a validated SupplierLeadTime promise and a series of observed receipt latencies, and the output is a SupplierLeadTime carrying the reconciled effective_hours, the sample count behind it, and a drift flag. Encoding the feed with pydantic means a row missing a unit is rejected at ingest instead of silently poisoning a mean.
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class TimeUnit(str, Enum):
HOURS = "hours"
CALENDAR_DAYS = "calendar_days"
BUSINESS_DAYS = "business_days"
WEEKS = "weeks"
class SupplierLeadTime(BaseModel):
"""Input and output contract for one supplier/SKU lead time."""
supplier_id: str = Field(..., min_length=3, max_length=64)
sku: str = Field(..., min_length=3, max_length=64)
promised_value: float = Field(..., gt=0)
promised_unit: TimeUnit
# Populated by reconciliation; None on ingest.
effective_hours: Optional[float] = Field(default=None, ge=0)
sample_count: int = Field(default=0, ge=0)
drift_flagged: bool = False
as_of: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@field_validator("supplier_id", "sku")
@classmethod
def strip_and_upper(cls, v: str) -> str:
return v.strip().upper()
The canonical work order remains the context this lead time ultimately serves: an effective lead time that is too optimistic is what turns a DEFER_PROCUREMENT on a critical part into a missed requested_completion. The site-wide WorkOrderPayload is reproduced verbatim so downstream reorder code can be copied without redefinition.
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, List, Optional
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))
Step-by-Step Implementation
1. Pull supplier lead-time records
Fetch the promised lead times over a non-blocking HTTP client and validate every row through SupplierLeadTime before it enters the pipeline. A row that fails validation — a missing unit, a zero duration, a blank supplier_id — is dropped and counted, never coerced. Fail closed if the token lacks procurement:read rather than reconciling against a partial feed.
import httpx
from typing import List, Tuple
async def pull_supplier_lead_times(
base_url: str, token: str, timeout_s: float = 10.0
) -> Tuple[List[SupplierLeadTime], int]:
"""Fetch and validate promised lead times; return (valid, rejected_count)."""
async with httpx.AsyncClient(timeout=timeout_s) as client:
response = await client.get(
f"{base_url.rstrip('/')}/api/v1/suppliers/lead-times",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
if response.status_code == 403:
raise PermissionError("token missing procurement:read scope")
response.raise_for_status()
valid: List[SupplierLeadTime] = []
rejected = 0
for row in response.json().get("lead_times", []):
try:
valid.append(SupplierLeadTime(**row))
except Exception:
rejected += 1
return valid, rejected
2. Normalize units and currencies of time
Promised lead times arrive in a currency of time that varies by supplier — one quotes calendar days, another quotes business days, a third quotes weeks. Convert every duration to canonical hours so the statistics are comparable. Business days must resolve against a working-week factor, because a supplier’s “5 business days” is not five 24-hour periods; treat it as working days times the daily working hours, not a raw multiplier of 24.
WORKING_HOURS_PER_DAY = 8.0
WORKING_DAYS_PER_WEEK = 5.0
_UNIT_TO_HOURS = {
TimeUnit.HOURS: 1.0,
TimeUnit.CALENDAR_DAYS: 24.0,
TimeUnit.WEEKS: 7.0 * 24.0,
}
def to_canonical_hours(value: float, unit: TimeUnit) -> float:
"""Convert any promised duration to elapsed calendar hours."""
if unit is TimeUnit.BUSINESS_DAYS:
# Business days map to elapsed calendar time via the working week.
weeks = value / WORKING_DAYS_PER_WEEK
return weeks * 7.0 * 24.0
try:
return value * _UNIT_TO_HOURS[unit]
except KeyError as exc:
raise ValueError(f"unhandled time unit: {unit}") from exc
3. Compute the rolling mean and percentile of actual latency
The actual side is measured, not quoted. For each supplier/SKU pair, take goods-receipt events inside the rolling window, compute the elapsed hours between order-placed and received, and summarize with both a mean and a high percentile. The percentile — not the mean — is what safety-stock timing should lean on, because it captures the tail delivery that strands a repair. pandas makes the windowed aggregation a single grouped pass.
import pandas as pd
from datetime import datetime, timezone, timedelta
def rolling_actual_latency(
receipts: pd.DataFrame, window_days: int, percentile: float
) -> pd.DataFrame:
"""Per supplier/SKU: mean and percentile of actual receipt latency (hours).
`receipts` columns: supplier_id, sku, placed_at, received_at (tz-aware).
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
recent = receipts[receipts["received_at"] >= cutoff].copy()
recent["latency_hours"] = (
recent["received_at"] - recent["placed_at"]
).dt.total_seconds() / 3600.0
# Guard against clock skew / bad rows producing negative latency.
recent = recent[recent["latency_hours"] > 0]
grouped = recent.groupby(["supplier_id", "sku"])["latency_hours"]
summary = grouped.agg(
actual_mean_hours="mean",
actual_pctl_hours=lambda s: s.quantile(percentile),
sample_count="count",
).reset_index()
return summary
4. Flag drift against the promised value
With a normalized promise and a measured distribution in the same unit, drift is the relative gap between what the supplier committed and what the tail actually delivers. Flag any pair whose actual percentile exceeds the promise by more than the configured fraction — that is the pair whose reorder point is quietly under-provisioned and whose contract is worth renegotiating.
def flag_drift(
promised_hours: float, actual_pctl_hours: float, drift_threshold: float
) -> bool:
"""True when observed latency outruns the promise beyond tolerance."""
if promised_hours <= 0:
return True # a missing/zero promise cannot be trusted
relative_gap = (actual_pctl_hours - promised_hours) / promised_hours
return relative_gap > drift_threshold
5. Publish one effective lead time per supplier and SKU
Blend the two sides into the number the reorder engine reads. When the sample count clears the minimum, trust the measured percentile; when history is too thin to be statistically meaningful, fall back to the promise so a brand-new SKU is not driven by two noisy receipts. Attach the drift flag and the sample count so downstream logic — and auditors — can see the provenance of every effective lead time.
import logging
logger = logging.getLogger(__name__)
def reconcile(
promise: SupplierLeadTime,
actual_mean_hours: float,
actual_pctl_hours: float,
sample_count: int,
min_samples: int,
drift_threshold: float,
) -> SupplierLeadTime:
"""Produce the effective lead time for one supplier/SKU pair."""
promised_hours = to_canonical_hours(promise.promised_value, promise.promised_unit)
if sample_count >= min_samples:
effective = actual_pctl_hours
drift = flag_drift(promised_hours, actual_pctl_hours, drift_threshold)
else:
# Too few receipts to trust the tail — lean on the contract.
effective = promised_hours
drift = False
result = promise.model_copy(
update={
"effective_hours": round(effective, 1),
"sample_count": sample_count,
"drift_flagged": drift,
}
)
logger.info(
"leadtime supplier:%s sku:%s promised_h:%.1f effective_h:%.1f n:%d drift:%s",
result.supplier_id, result.sku, promised_hours,
result.effective_hours, sample_count, drift,
)
return result
Configuration Reference
Keep every tunable in a version-controlled configuration registry, not in the job source. The defaults below are conservative starting points for general-purpose MRO replenishment.
| Parameter | Accepted values | Default | CMMS-specific notes |
|---|---|---|---|
LEADTIME_WINDOW_DAYS |
30–365 |
90 |
Rolling window over goods-receipt history; shorter reacts faster to supplier changes but is noisier on low-volume SKUs. |
percentile |
0.5–0.95 |
0.85 |
Percentile of actual latency used as the effective lead time; higher buys safety margin against tail deliveries. |
drift_threshold |
0.10–0.50 |
0.20 |
Relative gap of actual percentile over promise that raises a drift flag for contract review. |
min_samples |
3–20 |
6 |
Receipts required before trusting the measured tail; below this the promise is used. |
WORKING_HOURS_PER_DAY |
6–24 |
8 |
Working-day length used to convert business-day promises to calendar hours. |
negative_latency |
drop, clamp |
drop |
How to treat receipts whose received time precedes placed time (clock skew or bad data). |
stale_feed_max_age_h |
12–168 |
48 |
Maximum feed age before the job fails closed rather than reconciling against a stale promise. |
Validation and Testing
The unit conversion and drift math are pure, so the highest-value tests pin their behavior against fixed inputs. A business-day promise must convert to elapsed calendar hours through the working week, and a tail that outruns the promise must flag — both catch the mistakes that silently under-provision a reorder point.
def test_business_days_convert_through_working_week():
# 5 business days = 1 working week = 7 calendar days = 168 hours elapsed.
assert to_canonical_hours(5.0, TimeUnit.BUSINESS_DAYS) == 168.0
def test_tail_beyond_promise_flags_drift():
# Promise 120h, observed 85th-pctl 156h → 30% gap > 20% threshold.
assert flag_drift(120.0, 156.0, drift_threshold=0.20) is True
# Within tolerance stays quiet.
assert flag_drift(120.0, 132.0, drift_threshold=0.20) is False
def test_thin_history_falls_back_to_promise():
promise = SupplierLeadTime(
supplier_id="acme-bearings",
sku="PMP-BRG-0019",
promised_value=5.0,
promised_unit=TimeUnit.CALENDAR_DAYS,
)
result = reconcile(
promise, actual_mean_hours=300.0, actual_pctl_hours=340.0,
sample_count=2, min_samples=6, drift_threshold=0.20,
)
assert result.effective_hours == 120.0 # 5 calendar days, promise-driven
assert result.drift_flagged is False
assert result.sample_count == 2
On each run the job emits one structured line per reconciled pair — leadtime supplier:ACME-BEARINGS sku:PMP-BRG-0019 promised_h:120.0 effective_h:156.0 n:14 drift:True — which is the canonical signal that an effective lead time was published against measured receipts. A drift:True line is not an error; it is the intended alarm that a promise no longer matches reality. Assert against the presence of a line per active supplier/SKU pair in integration tests to confirm the full pull-to-publish path ran.
Failure Modes and Troubleshooting
Expand each scenario for the root cause, the diagnostic log excerpt, and the fix. The checklist items render as interactive checkboxes — work through them in order.
Promised lead time is trusted blindly and reorder points under-provision
Missing receipts skew the mean toward optimism
Supplier id mismatch splits one supplier across two lead times
Seasonal spikes inflate the effective lead time year-round
Frequently Asked Questions
Should the effective lead time be the promised value or the observed one?
Neither exclusively. When enough receipts exist to be statistically meaningful, publish the high percentile of observed latency, because that is what actually strands repairs. When history is too thin — a new SKU or a rarely ordered part — fall back to the promise so two noisy receipts do not drive the number. The min_samples parameter draws that line.
Why use a percentile instead of the mean of actual latency?
The mean hides the tail. A supplier that delivers in three days nine times out of ten and eleven days on the tenth has a mean that looks safe and a tail that blows an SLA. Safety-stock timing exists to survive that tail, so the published effective lead time leans on a high percentile — the percentile parameter — rather than the average.
How does this differ from automated reorder triggers?
This component only measures and publishes replenishment latency; it never sizes or places an order. It writes an effective lead time that automated reorder triggers reads when it decides when to fire a purchase order. Keeping lead-time measurement separate from order placement stops timing errors and ordering errors from masking each other.
What does a drift flag actually mean for procurement?
A drift_flagged pair is one where observed latency has outrun the promise beyond tolerance — the contract says five days, the tail delivers eleven. It is a signal to renegotiate the lead time with the supplier or re-source the part, not an automated action. The job surfaces the divergence; a human owns the contract decision.
Related
Publish effective lead times into automated reorder triggers so purchase orders fire on real latency, feed the same numbers into inventory threshold optimization so safety stock is sized against the tail, and pair the timing with the live stock reads from parts availability checks so a deferral carries an honest ETA. Anchor supplier and part identifiers to asset hierarchy design, and work the hard drift cases through reconciling supplier lead-time drift in reorder forecasts.
Part of: Asset Lookup & Inventory Synchronization.