Nightly Replenishment Planner: Stop the Double Orders
MediumAI-assisted coding · AI-Assisted Debugging
Nightly Replenishment Planner
You have joined the supply-chain engineering team at Harbor & Pine Provisions, a small retailer with one warehouse and a few hundred SKUs. Every night a planner decides what to buy from suppliers. It works on paper, but this morning buyers sent you ticket SUP-2291:
> *Tea was reordered three nights running: 90 units each time, same open PO. We now have 270 units of
> tea due and nothing else on the shelf. Also: cases keep arriving short, brand-new items are under-ordered,
> and an item sitting exactly at its reorder point was skipped.*
Your job is to fix the planner (Part 1), then teach it about multiple suppliers and partial shipments (Part 2), and finally support supplier minimum order values (Part 3). All quantities are integer units, all money is integer cents, all dates are YYYY-MM-DD. There is no floating point.
Codebase
| File | What it does |
|---|---|
replen/api.py | run(operations) entry point and the Dispatcher that routes every operation and owns the shared clock |
replen/planner.py | Planner: demand estimate and the nightly plan pass |
replen/policy.py | The arithmetic: demand average, safety stock, reorder point, inventory position, order sizing |
replen/catalog.py | SKU master data, supplier offers, on-hand and backorder balances (sell, receive) |
replen/orders.py | PurchaseOrderBook: places POs, tracks what is on order, applies deliveries lazily |
replen/history.py | Daily sales history per SKU |
replen/models.py | Sku, Offer, POLine, PurchaseOrder and the PO wire format |
replen/dates.py | YYYY-MM-DD parsing and formatting (dates are day numbers inside the code) |
replen/errors.py, replen/config.py | Error codes and constants (WINDOW_DAYS, PO prefix) |
replen/selection.py | Placeholder for Part 2 (supplier choice) |
replen/consolidation.py | Placeholder for Part 3 (one PO per supplier, order minimums) |
tests/ | Existing tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh planner and returns one result per operation. Every operation is [name, date, ...args]. Errors are returned as "error: <code>" strings, never raised.
| Operation | Arguments after date | Returns | Errors (checked in this order) |
|---|---|---|---|
add_sku | sku, on_hand, safety_days, cover_days | true | invalid arguments, sku exists |
add_supplier | sku, supplier, price_cents, lead_days, case_pack, moq, freight_cents | true | invalid arguments, unknown sku, supplier exists |
sale | sku, qty | [filled, backordered] | invalid arguments, unknown sku, invalid quantity |
plan | none | list of POs created, see below | none |
stock | sku | {"on_hand", "on_order", "backorder", "position"} | invalid arguments, unknown sku |
simulate | days, profile | summary dict (see Part 2) | invalid arguments, unknown sku |
skuandsupplierare non-empty strings.on_hand,safety_days,cover_days,lead_days,moq,freight_centsare ints>= 0;price_cents,case_packare ints (price_cents > 0,case_pack >= 1);qtyis an int> 0. Booleans and floats are never numbers. Anything else isinvalid arguments(aqtyof0or less, once the SKU is known, isinvalid quantity).- An unknown operation name is
error: unknown operation. A missing or malformeddate, a wrong number of arguments iserror: invalid arguments. - The shared clock. Dates never decrease. Any operation whose name, date and argument count are valid moves the clock to its date, even if it then fails with a business error. An operation dated before the clock returns
error: date went backwardsand changes nothing. - Deliveries are lazy. Nothing runs on a timer. Before executing any such operation, the planner applies every delivery that is due on or before that operation's date, oldest first. A delivery due on the 5th is therefore on the shelf for the first operation dated the 5th or later.
How the planner decides (Part 1 rules)
- Demand. The window is the 14 calendar days ending on the plan date, inclusive (the 1st to the 14th for a plan on the 14th). Days observed =
min(14, days since the SKU was added + 1); the day a SKU is added is day 1. Average daily demand =ceil(units requested in the window / days observed). Units requested include units that had to be backordered. No sales means demand 0. - Reorder point =
demand * lead_days + safety stock, where safety stock =demand * safety_daysandlead_daysis the SKU's supplier's. Part 1 SKUs use the first supplier added for them. - Inventory position =
on_hand + on_order - backorder. *On order* is the units on purchase orders that have not been delivered yet. *Backorder* is units sold that we did not have and still owe. - Trigger. Buy when
position <= reorder point. Thenneed = demand * cover_days - position; ifneed <= 0, buy nothing. - Quantity. Round
needup to a multiple of the case pack. The MOQ is also rounded up to a multiple of the case pack. Buy the larger of the two. - Purchase orders.
planvisits SKUs in ascending id order and places one PO per SKU it buys. POs are numberedPO-1,PO-2, ... in creation order. Each line is duelead_daysafter the plan date at the offer'sprice_cents. Freight is not part of the PO.planreturns the new POs:{"po", "supplier", "lines": [{"sku", "qty", "unit_cents", "due"}], "total_cents"}wheretotal_cents = sum(qty * unit_cents). - Sales and receipts. A sale takes what is on hand; the rest becomes a backorder. When goods arrive they go to backorders first and only the remainder to the shelf.
Part 1: Stop the double orders (about 15 minutes)
Make the planner behave as the rules above say. Start from the ticket, reproduce it with a test, and find the causes in the code; do not patch the symptoms in the API layer. Some causes sit in more than one file.
[["add_sku", "2025-03-01", "tea", 20, 2, 10],
["add_supplier", "2025-03-01", "tea", "acme", 300, 3, 6, 0, 0],
["sale", "2025-03-01", "tea", 10],
["plan", "2025-03-01"],
["plan", "2025-03-02"],
["stock", "2025-03-02", "tea"],
["sale", "2025-03-03", "tea", 30],
["stock", "2025-03-03", "tea"],
["plan", "2025-03-03"],
["stock", "2025-03-04", "tea"]]returns
[true, true, [10, 0],
[{"po": "PO-1", "supplier": "acme", "lines": [{"sku": "tea", "qty": 90, "unit_cents": 300, "due": "2025-03-04"}], "total_cents": 27000}],
[],
{"on_hand": 10, "on_order": 90, "backorder": 0, "position": 100},
[10, 20],
{"on_hand": 0, "on_order": 90, "backorder": 20, "position": 70},
[{"po": "PO-2", "supplier": "acme", "lines": [{"sku": "tea", "qty": 72, "unit_cents": 300, "due": "2025-03-06"}], "total_cents": 21600}],
{"on_hand": 70, "on_order": 72, "backorder": 0, "position": 142}]On 03-01 tea has demand 10, reorder point 10*3 + 20 = 50, position 10, target 100, so it buys 90 (15 cases of 6). On 03-02 the open PO keeps the position at 100: no second order. On 03-03 demand is ceil(40/3) = 14, reorder point 14*3 + 28 = 70, and the position 0 + 90 - 20 = 70 sits exactly on it, so tea buys 14*10 - 70 = 70, rounded up to 72. On 03-04 the first PO arrives and its 90 units clear the 20 backordered ones first.
A second example: a SKU added on 03-01 with on_hand 10, safety 1, cover 5, supplier lead 2, case pack 1, price 450, then ["sale", "2025-03-02", "mug", 20] returns [10, 10], and plan on 03-02 uses demand ceil(20/2) = 10 (two days observed) and buys 60.
Part 2: Several suppliers and partial shipments (about 25 minutes)
Procurement now sources most SKUs from several suppliers, and suppliers send advance ship notices (ASNs) when they can only ship part of a PO. Also, the finance team backtests the planner over years of history with simulate, and the backtest must run fast.
Multiple offers. add_supplier may now be called several times per SKU (the same supplier twice is still supplier exists). The first supplier added stays the SKU's *default*: the reorder point always uses the default supplier's lead_days, whichever supplier is finally chosen.
Choosing an offer. When a SKU triggers with need > 0, pick the supplier like this:
- *Days of cover* =
on_hand // demand(unlimited when demand is 0). This uses stock on hand only, not the inventory position. - An offer is *timely* if its
lead_days <= days of cover. - Landed cost per unit =
price_cents + freight_cents. - Among timely offers choose the lowest landed cost; ties go to the shorter
lead_days, then the smaller supplier id (string order). - If no offer is timely, choose the shortest
lead_days; ties go to the lower landed cost, then the smaller supplier id. - Size the order (Part 1 rule 5) with the chosen offer's case pack and MOQ; the line's
unit_centsis itsprice_centsand it is duelead_daysafter the plan date.
[["add_sku", "2025-03-01", "gadget", 70, 5, 12],
["add_supplier", "2025-03-01", "gadget", "swift", 500, 2, 10, 0, 0],
["add_supplier", "2025-03-01", "gadget", "mid", 380, 5, 10, 0, 40],
["add_supplier", "2025-03-01", "gadget", "slow", 400, 9, 10, 0, 0],
["sale", "2025-03-01", "gadget", 10],
["plan", "2025-03-01"]]Demand is 10, reorder point 10*2 + 50 = 70 (swift is the default), position 60, so it triggers and needs 120 - 60 = 60. Days of cover are 60 // 10 = 6: swift (landed 500, lead 2) and mid (landed 420, lead 5) are timely, slow (lead 9) is not. mid wins and plan returns one PO from mid for 60 units at 380 (total_cents 22800, due 2025-03-06).
Advance ship notices. ["asn", date, po_id, sku, deliveries] replaces the delivery schedule of one SKU line of a PO. deliveries is a list of [date, qty] pairs. By default a line has one delivery of its whole quantity on its due date. After an ASN the line is delivered exactly as listed; any units not listed are cancelled and no longer count as on order. Details:
- Deliveries land lazily (see "Deliveries are lazy") in date order; same-date pairs land in the order listed. A delivery dated the ASN's own date lands before the next operation.
- The listed quantities may not exceed what is still outstanding on that line (ordered minus received minus anything already cancelled). An empty list cancels everything still outstanding. A second ASN replaces the schedule set by the first.
on_orderand therefore the inventory position follow the schedule, so cancelled units can be reordered by the nextplan.- Returns
true. Errors, in this order:invalid arguments(po or sku not a non-empty string, deliveries not a list),unknown po,sku not on po,invalid delivery(a pair that is not[valid date, int > 0], or a date before the ASN's own date),exceeds outstanding.
[["add_sku", "2025-03-01", "widgets", 50, 0, 10],
["add_supplier", "2025-03-01", "widgets", "forge", 100, 5, 10, 0, 0],
["sale", "2025-03-01", "widgets", 40],
["plan", "2025-03-01"],
["asn", "2025-03-02", "PO-1", "widgets", [["2025-03-04", 100], ["2025-03-07", 150]]],
["stock", "2025-03-02", "widgets"],
["stock", "2025-03-05", "widgets"],
["stock", "2025-03-06", "widgets"],
["stock", "2025-03-07", "widgets"]]plan buys 390 (40*10 - 10, due 03-06). The ASN keeps only 250 of them. The stock reads are {on_hand 10, on_order 250, backorder 0, position 260}, {110, 150, 0, 260} (the 03-04 delivery landed lazily), {110, 150, 0, 260} (the original 03-06 delivery no longer exists) and {260, 0, 0, 260}.
Backtests. ["simulate", date, days, profile] replays days consecutive days starting at date (1 to 4000). profile maps SKU to [base, swing] (non-negative ints). On day offset i the SKU at index j of the sorted profile keys sells base + (i*i + 3*i + 5*j) % (swing + 1) units (skipped when 0), then the nightly plan runs for all SKUs. It returns {"pos": POs created, "units": units ordered, "short_days": SKU-days on which a sale was partly backordered, "backorder": units owed at the end}. simulate is already implemented through the same code paths as the single operations; tests run up to about 3,500 days across ten SKUs and a full run must stay within a few seconds.
Part 3: Order minimums and consolidated POs (about 15 minutes)
Procurement signed supplier agreements: suppliers have a minimum order value, and they want one consolidated PO per night rather than a PO per SKU. This replaces the "one PO per SKU" rule from Part 1.
New operation: ["set_minimum", date, supplier, cents] sets that supplier's minimum order value in cents (int >= 0, supplier a non-empty string; the supplier need not have offers yet) and returns true; else error: invalid arguments. The default minimum is 0. Setting it again replaces it.
Rules for plan:
- Choose each triggered SKU's offer exactly as in Part 2 (minimums play no part in the choice). Group the triggered SKUs by chosen supplier. Suppliers are processed in ascending supplier id.
- A supplier's PO has one line per SKU, in ascending SKU id, and its value is
sum(qty * price_cents); freight is not counted. Ifvalue >= minimum, place the PO. POs are numbered in the order placed. - Otherwise pull forward. A candidate is a SKU that did not trigger tonight (
position > reorder point) withdemand * cover_days - position > 0, that has an offer from this supplier, and that no earlier supplier pulled tonight. Rank candidates byposition - reorder pointascending (units, not days), ties by SKU id. Add them one at a time, sized with this supplier's offer (price, case pack, MOQ, lead time), and stop as soon asvalue >= minimum. - If candidates run out and the value is still short, skip this supplier's PO: nothing is placed, no PO number is used, nothing is pulled, and its triggered SKUs are not bought tonight and not rerouted to another supplier. They are evaluated again at the next
plan. - A SKU triggered with a supplier is never a pull-forward candidate for another supplier.
[["add_sku", "2025-03-01", "a", 20, 1, 6], ["add_supplier", "2025-03-01", "a", "fresh", 100, 2, 1, 0, 0],
["add_sku", "2025-03-01", "b", 18, 1, 20], ["add_supplier", "2025-03-01", "b", "fresh", 100, 2, 1, 0, 0],
["add_sku", "2025-03-01", "c", 22, 1, 40], ["add_supplier", "2025-03-01", "c", "fresh", 100, 2, 1, 0, 0],
["set_minimum", "2025-03-01", "fresh", 9000],
["sale", "2025-03-01", "a", 10], ["sale", "2025-03-01", "b", 3], ["sale", "2025-03-01", "c", 1],
["plan", "2025-03-01"]]Only a triggers (demand 10, reorder point 30, position 10, buys 50 for 5000 cents). That is under 9000, so the planner pulls forward. b (reorder point 9, position 15, gap 6, needs 45) is closer than c (gap 18) and brings the value to 9500 >= 9000, so c stays out. plan returns one PO, PO-1 from fresh, with lines a x 50 and b x 45 (total_cents 9500).
With no minimums, two SKUs that trigger with the same supplier simply share one PO: tea (supplier alpine) and oat (supplier alpine) on one PO, and jam (supplier zenith) on PO-2, because alpine sorts before zenith. Shipment notices still address one SKU line (["asn", date, "PO-1", "oat", ...]).
Parts 1 and 2 must keep working: selection, ASNs, backorders and the backtest all go through the new consolidated planner.
Example 1
- Input:
- [["add_sku","2025-03-01","tea",20,2,10],["add_supplier","2025-03-01","tea","acme",300,3,6,0,0],["sale","2025-03-01","tea",10],["plan","2025-03-01"],["plan","2025-03-02"],["stock","2025-03-02","tea"]]
- Output:
- [true,true,[10,0],[{"po":"PO-1","supplier":"acme","lines":[{"sku":"tea","qty":90,"unit_cents":300,"due":"2025-03-04"}],"total_cents":27000}],[],{"on_hand":10,"on_order":90,"backorder":0,"position":100}]
- Explanation
- Tea buys 90 units tonight. Tomorrow the open PO is counted in the inventory position, so nothing is bought again.
Example 2
- Input:
- [...gadget with offers swift (500, lead 2), mid (380 + 40 freight, lead 5), slow (400, lead 9)..., ["sale","2025-03-01","gadget",10],["plan","2025-03-01"]]
- Output:
- [..., [{"po":"PO-1","supplier":"mid","lines":[{"sku":"gadget","qty":60,"unit_cents":380,"due":"2025-03-06"}],"total_cents":22800}]]
- Explanation
- Stock covers 6 days, so slow (9 days) cannot arrive in time; mid has the lowest landed cost among swift and mid.
Example 3
- Input:
- [...a, b, c from one supplier "fresh" with minimum 9000..., ["plan","2025-03-01"]]
- Output:
- [..., [{"po":"PO-1","supplier":"fresh","lines":[{"sku":"a","qty":50,...},{"sku":"b","qty":45,...}],"total_cents":9500}]]
- Explanation
- Only a triggers (5000 cents, under the minimum), so the planner pulls forward b, the non-triggered SKU closest to its reorder point, and stops once the value reaches the minimum.
Guidelines
- Python 3 standard library only
- Units, cents and days are integers; there is no floating-point arithmetic anywhere
- Dates are YYYY-MM-DD strings and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 2 includes a multi-year backtest with tens of thousands of nightly plans