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.

  1. The threshold check ignores quantity already on order. on_hand_qty < reorder_point is true on cycle one, and it stays true on cycles two and three because a purchase order does not change on_hand_qty — only a goods receipt does. The evaluator has no concept of “this shortage is already being handled.”
  2. 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.
  3. 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.

Reorder decision flow: naive re-fire versus on-order suppression with an idempotency key A before/after decision ladder. In the before path, the evaluator checks only on_hand against reorder_point on every cycle; while stock stays below the point, each of three consecutive cycles independently concludes a reorder is required and calls the procurement API with no dedup key, producing three purchase orders for the same shortage. In the after path, the evaluator first computes available to promise as on_hand plus on_order minus allocated, then checks that figure against the reorder point; if on_order already covers the gap it suppresses the reorder, and if a new PO is still required it builds an idempotency key from the SKU and the current reorder window before calling the procurement API, so a retried or repeated cycle within the same window reuses the existing PO instead of creating a duplicate. Why the same shortage raises three POs — and how to stop at one BEFORE — naive re-fire Cycle 1 · 09:05 on_hand=3 < 10 no on_order check -> PO-55190 Cycle 2 · 09:10 on_hand=3 < 10 still no memory -> PO-55191 Cycle 3 · 09:15 on_hand=3 < 10 no idempotency key -> PO-55192 3 open POs 120 units ordered for a 40-unit need caught after the fact AFTER — ATP + idempotency key Compute ATP on_hand + on_order − allocated = available_to_promise ATP < reorder_point? on_order already covers the gap -> suppress gap open Build key sku + reorder_window stable per gap idempotency_key Exactly one PO retries dedup Cycles 2 and 3 re-evaluate the same shortage Cycle 2: ATP still < 10, key unchanged Cycle 3: ATP still < 10, key unchanged Procurement API returns 409 Conflict Same idempotency_key -> existing PO-55190 is reused, not duplicated, until on_order covers the gap or stock is received in · InventorySnapshot{part_sku, on_hand_qty, on_order_qty, allocated_qty, reorder_point} → out · ReorderDirective{order_qty, idempotency_key, suppressed} Reorder window A stable time bucket (for example, the shortage's first-detected day) keeps the idempotency key constant across every re-evaluation of the same unresolved gap, so retries and reruns collapse onto one PO.

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.

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.