Stopping Duplicate Purchase Orders from Automated Reorder Triggers
An automated reorder trigger that only checks on_hand < reorder_point re-fires on every evaluation cycle while stock stays below the line, and because the first purchase order does not lift on_hand until it is received, the engine reads the same shortage again and again. Three, five, sometimes a dozen identical purchase orders land at the vendor for one part before the original ever arrives. This page traces that failure to its exact cause — a threshold check with no memory of quantity already on order and no idempotency key to stop a re-evaluation from acting twice — and resolves it with an available-to-promise calculation plus a deterministic dedup key.
Incident Profile
A maintenance site runs its reorder evaluator on a five-minute cron cycle against the reconciled inventory ledger. A bearing SKU drops below its reorder point after a run of unplanned repairs, and the evaluator raises a purchase order. Because the evaluator only re-reads on_hand_qty, and the vendor has not yet shipped, the next three cycles see the exact same shortage and raise three more purchase orders for the same part before anyone notices.
2026-07-14 09:05:01,102 INFO [reorder_evaluator] Cycle start: evaluating 214 SKUs against reorder_point
2026-07-14 09:05:01,340 WARNING [reorder_evaluator] BRG-7731: on_hand=3 < reorder_point=10 -> reorder required
2026-07-14 09:05:01,341 INFO [procurement_client] PO created: PO-55190 sku=BRG-7731 qty=40 vendor=VEND-118
2026-07-14 09:10:01,088 INFO [reorder_evaluator] Cycle start: evaluating 214 SKUs against reorder_point
2026-07-14 09:10:01,312 WARNING [reorder_evaluator] BRG-7731: on_hand=3 < reorder_point=10 -> reorder required
2026-07-14 09:10:01,313 INFO [procurement_client] PO created: PO-55191 sku=BRG-7731 qty=40 vendor=VEND-118
2026-07-14 09:15:01,095 INFO [reorder_evaluator] Cycle start: evaluating 214 SKUs against reorder_point
2026-07-14 09:15:01,318 WARNING [reorder_evaluator] BRG-7731: on_hand=3 < reorder_point=10 -> reorder required
2026-07-14 09:15:01,319 INFO [procurement_client] PO created: PO-55192 sku=BRG-7731 qty=40 vendor=VEND-118
2026-07-14 09:15:02,004 ERROR [procurement_reconciler] BRG-7731: 3 open POs (55190, 55191, 55192) totalling 120 units against a required 40 -> over-order flagged
Three purchase orders, fifteen minutes, one shortage. The vendor now holds three separate acknowledgements for the same 40-unit need, the accounts payable team will see three invoices land against a single repair, and the reconciler only catches the over-order after the fact — the damage is already committed at the vendor.
Root Cause Analysis
The evaluator’s logic is a single, incomplete comparison repeated on a timer.
- The threshold check ignores quantity already on order.
on_hand_qty < reorder_pointis true on cycle one, and it stays true on cycles two and three because a purchase order does not changeon_hand_qty— only a goods receipt does. The evaluator has no concept of “this shortage is already being handled.” - There is no idempotency key tying a purchase order to the shortage event that caused it. Every cycle calls the procurement API with a fresh request body and no deduplication token, so the endpoint has no way to recognise “I have already seen this SKU’s current shortage” and correctly creates a new PO each time.
- The evaluation cycle and the vendor lead time operate on different clocks. The reorder engine re-evaluates every five minutes; the vendor confirmation and eventual receipt take days. Any check that only looks at the instantaneous ledger, without also looking at what has already been ordered, will re-fire for the entire gap between those two clocks — exactly the drift this component is meant to prevent once it is reconciled with supplier lead-time sync.
This is the procurement-side counterpart to a stale read on the availability side: just as an uncached parts availability check can dispatch a technician against phantom stock, an uncached reorder evaluation can dispatch a purchase order against a phantom shortage — one that a prior cycle already resolved.
Resolution
The fix has two independent parts: compute a demand figure that already nets out quantity on order, and make the dispatch call idempotent so a retried or repeated evaluation cannot create a second purchase order even if the first check is somehow bypassed.
Before — checks raw on_hand, no dedup key:
import requests
def evaluate_reorder(part_sku: str, on_hand_qty: int, reorder_point: int, order_qty: int) -> None:
# Only looks at physical stock; a PO already placed does not change on_hand_qty,
# so this condition stays true on every cycle until the shipment is received.
if on_hand_qty < reorder_point:
requests.post(
"https://procurement.internal/api/v1/purchase-orders",
json={"part_sku": part_sku, "quantity": order_qty},
timeout=5.0,
)
# No idempotency key attached -> the endpoint treats every call as a new order.
After — available-to-promise math plus a stable idempotency key:
import hashlib
from datetime import date
import requests
def available_to_promise(on_hand_qty: int, on_order_qty: int, allocated_qty: int) -> int:
"""Net demand figure: physical stock plus what is already inbound, minus what is
already spoken for by open work orders. This is the number the reorder decision
must use — never on_hand_qty alone."""
return on_hand_qty + on_order_qty - allocated_qty
def reorder_window_key(part_sku: str, window_date: date) -> str:
"""A stable key for the *current, unresolved* shortage on this SKU. Using the
calendar day the shortage was first evaluated (not the evaluation timestamp)
keeps the key constant across every re-run within the same open gap."""
raw = f"{part_sku}:{window_date.isoformat()}"
return hashlib.sha256(raw.encode()).hexdigest()[:24]
def evaluate_reorder(
part_sku: str,
on_hand_qty: int,
on_order_qty: int,
allocated_qty: int,
reorder_point: int,
order_qty: int,
window_date: date,
) -> dict:
atp = available_to_promise(on_hand_qty, on_order_qty, allocated_qty)
if atp >= reorder_point:
# on_order (plus on_hand, minus allocations) already covers the gap.
# A prior PO is doing its job; firing another would over-order.
return {"sku": part_sku, "suppressed": True, "reason": "atp_covers_gap", "atp": atp}
idempotency_key = reorder_window_key(part_sku, window_date)
resp = requests.post(
"https://procurement.internal/api/v1/purchase-orders",
json={"part_sku": part_sku, "quantity": order_qty},
headers={"Idempotency-Key": idempotency_key},
timeout=5.0,
)
# A well-behaved procurement API returns 201 on first creation and 409 (or a
# replayed 201 with the original PO id) on every subsequent call with the same key.
resp.raise_for_status()
return {"sku": part_sku, "suppressed": False, "idempotency_key": idempotency_key, "status": resp.status_code}
The available-to-promise formula is the load-bearing change: on_hand_qty + on_order_qty - allocated_qty folds in exactly the quantity the naive check was missing. The idempotency key is the safety net for the case ATP alone cannot cover — a retried HTTP call, a rerun cycle inside the same evaluation window, or a race between two workers — because it gives the procurement endpoint a deterministic way to recognise “this is the same shortage I already acted on.”
Minimal Reproducible Pipeline
This script runs end-to-end. It defines the canonical WorkOrderPayload (with the site-wide SLA fields priority, requested_completion, and escalation_tier) for the shortage that triggers the reorder, an ATP-aware evaluator, and a fake procurement client that enforces idempotency the way a real endpoint would.
import hashlib
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, timezone
from enum import Enum
from typing import Dict, List, Optional
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("reorder_evaluator")
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))
@dataclass
class InventorySnapshot:
part_sku: str
on_hand_qty: int
on_order_qty: int
allocated_qty: int
reorder_point: int
reorder_qty: int
class FakeProcurementAPI:
"""Stands in for a real endpoint that enforces Idempotency-Key semantics:
the same key always returns the same PO id instead of creating a new one."""
def __init__(self) -> None:
self._pos_by_key: Dict[str, str] = {}
self._next_id = 55190
def create_purchase_order(self, part_sku: str, quantity: int, idempotency_key: str) -> tuple[str, bool]:
if idempotency_key in self._pos_by_key:
return self._pos_by_key[idempotency_key], False # replay, not created
po_id = f"PO-{self._next_id}"
self._next_id += 1
self._pos_by_key[idempotency_key] = po_id
return po_id, True # newly created
def available_to_promise(snapshot: InventorySnapshot) -> int:
return snapshot.on_hand_qty + snapshot.on_order_qty - snapshot.allocated_qty
def reorder_window_key(part_sku: str, window_date: date) -> str:
raw = f"{part_sku}:{window_date.isoformat()}"
return hashlib.sha256(raw.encode()).hexdigest()[:24]
def evaluate_cycle(
api: FakeProcurementAPI,
snapshot: InventorySnapshot,
window_date: date,
triggering_wo: WorkOrderPayload,
) -> Optional[str]:
atp = available_to_promise(snapshot)
if atp >= snapshot.reorder_point:
logger.info("%s: ATP=%d covers reorder_point=%d, suppressing", snapshot.part_sku, atp, snapshot.reorder_point)
return None
key = reorder_window_key(snapshot.part_sku, window_date)
po_id, created = api.create_purchase_order(snapshot.part_sku, snapshot.reorder_qty, key)
if created:
logger.warning(
"%s: ATP=%d < reorder_point=%d -> %s created for WO %s (escalation_tier=%d)",
snapshot.part_sku, atp, snapshot.reorder_point, po_id, triggering_wo.work_order_id, triggering_wo.escalation_tier,
)
else:
logger.info("%s: cycle re-fired within window, reused existing %s (no duplicate)", snapshot.part_sku, po_id)
return po_id
if __name__ == "__main__":
api = FakeProcurementAPI()
today = date(2026, 7, 14)
shortage_wo = WorkOrderPayload(
work_order_id="WO-91007",
asset_id="PUMP-31",
part_skus=["BRG-7731"],
required_quantities={"BRG-7731": 1},
location_id="WH-04",
priority=Priority.HIGH,
escalation_tier=1,
)
# Cycle 1: on_hand=3, nothing on order yet -> reorder fires.
snap = InventorySnapshot(part_sku="BRG-7731", on_hand_qty=3, on_order_qty=0, allocated_qty=0, reorder_point=10, reorder_qty=40)
po_first = evaluate_cycle(api, snap, today, shortage_wo)
# Cycle 2 (five minutes later): on_hand still 3, but on_order_qty now reflects
# the PO just placed -> ATP includes it and the evaluator suppresses a repeat.
snap = InventorySnapshot(part_sku="BRG-7731", on_hand_qty=3, on_order_qty=40, allocated_qty=0, reorder_point=10, reorder_qty=40)
po_second = evaluate_cycle(api, snap, today, shortage_wo)
# Cycle 3: even if on_order_qty were not yet reflected (a lagging sync), the
# idempotency key for the same SKU + window reuses PO-55190 instead of duplicating.
snap = InventorySnapshot(part_sku="BRG-7731", on_hand_qty=3, on_order_qty=0, allocated_qty=0, reorder_point=10, reorder_qty=40)
po_third = evaluate_cycle(api, snap, today, shortage_wo)
assert po_first == "PO-55190"
assert po_second is None
assert po_third == "PO-55190"
logger.info("Result: exactly one PO (%s) for one shortage across three cycles.", po_first)
Run it and confirm the log shows a single PO-55190 created on cycle one, a suppression on cycle two once on_order_qty reflects the open order, and a reused (not duplicated) PO-55190 on cycle three even when the sync lags. Wiring this evaluator against live data means pulling on_order_qty from the same reconciled ledger that feeds inventory threshold optimization, and folding in confirmed vendor ship dates from supplier lead-time sync so the reorder window does not close before a delayed shipment actually lands.
Prevention Checklist
FAQ
Why not just add a cooldown timer instead of tracking on_order_qty?
A cooldown suppresses re-fires for a fixed window regardless of whether the shortage was actually addressed, which either blocks a legitimate second reorder (if consumption continues after the first PO) or reopens the duplicate window the moment the timer expires. Available-to-promise suppresses based on the real state of the gap, so it neither over-blocks nor re-opens on a schedule unrelated to procurement reality.
What if the procurement API doesn’t support idempotency keys natively?
Keep a local dedup table keyed on the same reorder_window_key and check it before calling the API; treat a hit as “already dispatched” and skip the call. It is weaker than server-side idempotency (a crash between the check and the call can still duplicate), but it closes the vast majority of the same-cycle re-fire cases this page addresses.
How long should a reorder window stay open?
Tie it to the SKU’s confirmed lead time from supplier lead-time sync rather than a fixed constant — a window that closes before the vendor’s actual ship date reopens the gap and risks a second, legitimate-looking PO before the first has even shipped.
Does this also stop thrashing between reorder and no-reorder states?
No — that is a separate failure where the reorder point itself oscillates with noisy demand; see fixing thrashing reorder points from noisy demand for that fix. This page only stops one shortage from producing more than one purchase order.
Related
This failure sits inside automated reorder triggers, the procurement-signalling stage of asset lookup and inventory synchronization; confirm the shortage itself is real (not a stale read) with parts availability checks, keep reorder points stable with inventory threshold optimization, and size the reorder window against real vendor dates with supplier lead-time sync.
Part of: Automated Reorder Triggers.