Warehouse Pick Waves: Walk Order, Short Picks and Zone Picking
HardAI-assisted coding · AI-Assisted Feature Work
Warehouse Pick Waves: Walk Order, Short Picks and Zone Picking
You've joined the fulfillment-systems team at a regional e-commerce distribution center. The warehouse management system (WMS) knows the stock, the bins and the orders, but it still prints pick sheets the way the old system did: nobody has built the part that turns open orders into waves, tells each picker where to walk, and copes with what happens when a bin turns out to be empty. That is this quarter's roadmap, in four tickets. Every rule below is deterministic: there is no clock and no randomness, and the same operations always give the same results.
Codebase
| File | What it does |
|---|---|
wms/api.py | run(operations) entry point and the Dispatcher that routes each operation |
wms/waves.py | WaveService: release (Part 1), pick-list lookup, zone mode and consolidation (Part 3) |
wms/route.py | The walk order of a pick list (stub, Part 1) |
wms/totes.py | ToteBook: packing units into totes (stub, Part 1) |
wms/shortpicks.py | PickDesk: confirming picks, short picks, order status (stub, Part 2) |
wms/allocator.py | allocate_line: which lots an order line is filled from |
wms/inventory.py | Inventory: bins, lots, reservations, stock movements |
wms/orders.py, wms/catalog.py | Orders; SKUs and carriers |
wms/models.py | The records everything shares (Lot, Order, PickTask, PickList, Wave, Settings) |
wms/views.py | Renders waves and pick lists (wave_view, wave_summary, list_digest, consolidation) |
wms/locations.py, wms/checks.py, wms/errors.py, wms/config.py | Location parsing, argument checks, error codes, defaults |
wms/loadgen.py | fill_zone and order_series: compact generators for load tests |
tests/ | Example tests for the parts that exist today |
Read models.py and views.py first: PickTask, PickList and Wave are the contract between the code you write and the reports. locations.py, catalog.py, orders.py, views.py and loadgen.py are known-good.
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh warehouse and returns one result per operation. Every operation is [name, *args]. Failures are returned as {"error": "<code>"}, never raised, and they change nothing.
- An unknown operation name returns
{"error": "unknown operation"}. A wrong number of arguments returns{"error": "invalid arguments"}; that check comes before every other check. - An operation whose code is not written yet returns
{"error": "not supported"}. - Integers never include
true/false. Days are integer day numbers (0,1, ...); a perishable lot'sexpiryis a day number too. Weights are integer grams. - Locations are written
<zone>-<aisle>-<bay>-<level>, e.g.B-07-12-3: one capital letter, aisle01-99, bay01-99, level1-9(1 = floor).wms/locations.pyis the one parser.
Operations that already work
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
config | key, value | true | unknown config key, invalid value |
sku | sku, unit_grams, perishable, min_shelf_days | true | invalid sku, sku exists |
carrier | name, cutoff | true | invalid carrier, carrier exists |
bin | location, sku, kind | true | invalid location, sku not found, invalid kind, bin exists |
receive | location, lot, qty, expiry | the bin's total on hand | bin not found, invalid lot, invalid quantity, invalid expiry, lot conflict |
order | order_id, carrier, lines | true | invalid order, order exists, carrier not found, sku not found |
stock | sku | {"face_on_hand", "face_reserved", "reserve_on_hand"} | sku not found |
bins | zone or null | [[location, sku, kind, on_hand], ...] in stock-room order | invalid zone |
fill_zone, order_series | see wms/loadgen.py | number created |
configkeys aremax_wave_lines(default 8) andmax_tote_grams(default 12000); values are integers>= 1. A change applies to waves planned afterwards.sku:unit_gramsis an integer> 0,perishablea boolean,min_shelf_daysan integer>= 0.carrier:cutoffis minutes after midnight,0-1439; carriers may share a cutoff.- A
binis a"face"(a pick face) or a"reserve"(overstock) and holds lots of one SKU. Only pick faces are ever allocated; reserve stock only shows up instock. receivecreates a lot in the bin or adds to the lot with the same id. A perishable SKU needs an integerexpiry >= 0; any other SKU needsnull. Each lot gets a receipt sequence number (1, 2, ...) when it is first received; adding to it later does not change it. A lot id is a non-empty string (invalid lot),qtyan integer>= 1, and receiving an existing lot with a differentexpiryis alot conflict.- An
orderhaslines: a non-empty list of[sku, qty]with distinct SKUs andqty >= 1. Orders are numbered 1, 2, ... in the order they were placed; that number is the order's seq. - Stock numbers: a lot's on hand is what the system believes is on the shelf, reserved is what is promised to pick tasks nobody has confirmed yet, and available is the difference.
Worked example (used again in Parts 1 and 2)
[["sku", "MILK", 1000, true, 2], ["sku", "TEA", 400, false, 0], ["carrier", "UPS", 600],
["bin", "A-01-05-1", "MILK", "face"], ["bin", "A-02-03-1", "MILK", "face"],
["bin", "A-02-08-1", "TEA", "face"], ["bin", "A-03-02-1", "TEA", "face"],
["receive", "A-01-05-1", "M1", 10, 14], ["receive", "A-02-03-1", "M2", 10, 12],
["receive", "A-02-08-1", "T1", 5, null], ["receive", "A-03-02-1", "T2", 3, null],
["config", "max_tote_grams", 3000],
["order", "O1", "UPS", [["MILK", 2], ["TEA", 4]]],
["order", "O2", "UPS", [["TEA", 3]]]]returns true for every operation except the four receive calls, which return 10, 10, 5, 3.
---
Part 1 — Release the waves (WMS-4501)
Goal: implement release, which takes every open order, groups them into waves, reserves stock for them, and returns one pick list per wave with the stops in the order a single picker walks them and with every unit assigned to a tote.
["release", today] returns the list of waves it created ([] if no order is open). today is an integer day >= 0 (errors in order: invalid day). ["pick_list", list_id] returns one list again (list not found).
The walk
A picker walks the building like this: zones in letter order (A, then B, ...); inside a zone, aisles in ascending number; inside an aisle the direction depends on the aisle number: an odd aisle is walked front to back (bay ascending) and an even aisle back to front (bay descending). Inside one bay, levels always ascend (floor first), in either direction. The direction comes from the number printed on the aisle, never from how many aisles the picker has already visited: if aisle 3 has no picks, aisle 4 is still walked back to front. Two stops in the same bin are ordered by the order's seq, then by the order line's index (first line of the order first), then by lot id (plain string order).
Planning waves
- Open orders are the ones that have not been released yet (including orders held by an earlier release, rule 6). Sort them by their carrier's cutoff (earlier first), then by seq.
- Walk that sorted list and fill waves next-fit: an order joins the current wave if it has the same cutoff and the wave's total number of order lines (not units) stays
<= max_wave_lines. Otherwise it starts a new wave. A wave never reopens, and an order is never split, so an order with more lines than the limit gets a wave to itself. Two carriers with the same cutoff share waves. - Waves are named
W1,W2, ... in creation order, for the whole life of the warehouse (the counter continues across releases). Planning counts every line of every order in the batch, including the ones of orders that end up held in rule 6.
Allocating stock
- Allocate wave by wave in wave order, order by order in wave order, line by line in the order given, so earlier orders get first call on stock. Only pick faces are allocated.
- For one line: a lot is usable if it has available stock and, for a perishable SKU, its expiry day minus
todayis at leastmin_shelf_days. Take units from the usable lots in this order, until the line is covered: perishable SKUs by expiry (earliest first), then receipt sequence; other SKUs by receipt sequence (earliest first). Taking units from a lot reserves them. One line can be filled from several lots; each lot used becomes one pick task. - An order is all or nothing. If any line cannot be covered completely, nothing is kept: the stock reserved for its earlier lines goes back and the order is held. It stays open, is not part of the wave's picks, and is planned again by the next
release. (Stock a held order gave back is available to the orders after it.)
Pick lists and totes
- A wave's picks form one list named like the wave (
W1), picker label"roam". Sort its tasks in walk order, number them1, 2, 3, ...in that order, then pack totes in that order (rule 8). A wave in which no order got stock has no list at all. - Totes. A tote holds units of one order only. Totes of a list are named
<list id>-T1,<list id>-T2, ... in the order they are opened. For each task in turn, place its units into its order's most recently opened tote on this list while the tote's weight (units times the SKU'sunit_grams) stays<= max_tote_grams(equal is fine). Units that don't fit go into a newly opened tote; so one task can span several totes. Only the most recent tote of an order is ever filled; earlier ones are closed even if a lighter item would still fit. A newly opened tote always takes at least one unit, even if one unit alone is over the limit. - Each wave remembers the
todayit was released on and themax_tote_gramsin force then.
What release returns
{"wave": "W1", "cutoff": 600, "orders": ["O1", "O2"], "held": [],
"lists": [{"list": "W1", "picker": "roam", "picks": [ ...rows... ]}],
"consolidate": []}orders are the orders that got stock, in wave order; held the ones that did not. Each row is a list [no, loc, sku, lot, order, qty, totes, status, picked] with totes as a list of "<tote id>x<units>" strings (e.g. "W1-T3x1"), status "pending" and picked 0 at release (wms/views.py already renders this). consolidate is always [] until Part 3. pick_list returns {"list", "wave", "picker", "picks": [rows]}.
Example (the world above, then ["release", 10]). The picks of list W1:
| no | loc | sku | lot | order | qty | totes |
|---|---|---|---|---|---|---|
| 1 | A-02-08-1 | TEA | T1 | O1 | 4 | W1-T1 ×4 |
| 2 | A-02-08-1 | TEA | T1 | O2 | 1 | W1-T2 ×1 |
| 3 | A-02-03-1 | MILK | M2 | O1 | 2 | W1-T1 ×1, W1-T3 ×1 |
| 4 | A-03-02-1 | TEA | T2 | O2 | 2 | W1-T2 ×2 |
MILK comes from lot M2 (expires day 12) before M1 (day 14): on day 10 it still has exactly the 2 shelf days MILK requires. Aisle 2 is even, so bay 08 is walked before bay 03, then aisle 3. O1's tote W1-T1 holds 4 × 400 g of tea plus one 1000 g carton of milk (2600 g); the second carton does not fit under 3000 g and opens W1-T3.
---
Part 2 — Confirm picks and handle short picks (WMS-4533)
Goal: pickers confirm each stop on a handheld. When a bin has fewer units than the system expected (a short pick), the system must stop sending anyone to that bin, find the missing units somewhere else, and backorder what it cannot find.
["confirm", list_id, no, picked] reports that the picker took picked units at stop no. Errors, in order: list not found, pick not found (no is not an integer or not a task number of that list), pick not pending (the task is already picked, short or cancelled), invalid quantity (picked is not an integer in 0..qty).
Full pick (picked == qty): the units leave the shelf (on hand and reserved both drop by qty), the task becomes picked, and the result is {"status": "picked", "qty": <qty>}.
Short pick (picked < qty, zero included):
- The picked units leave the shelf and the task becomes
shortwith itspickedcount. - The picker found the bin empty: every lot in that bin is written off to zero on hand.
- Every other task that is still
pendingand aims at that bin, in any list of any wave, is cancelled (statuscancelled) and its reservation is given back. - The shortfall of the confirmed task (
qty - picked) and the full quantity of every cancelled task are needs. Process them in this order: the confirmed task first, then the cancelled tasks by wave number, then list, thenno. Allocate each need again with the Part 1 rules (rule 5) and the same wave'stoday; the emptied bin has nothing left, so the units come from other bins. Earlier needs get first call on stock. - Each allocated lot becomes a new
pendingtask for the same order and order line. The new tasks of a list are sorted in walk order and appended after the list's existing tasks, numbered continuing from its last number (nois never reused or renumbered). Their units go into totes with rule 8, continuing from where that list's totes stopped: the plan is not redone, so the planned units of the short or cancelled tasks still count toward the weight of their totes, and the limit used is the wave's ownmax_tote_grams, even ifconfigchanged afterwards. - Units a need could not get are backordered: added to the order's
backorderedcount for that SKU. Nothing re-plans a backorder.
The result is {"status": "short", "picked", "short": qty - picked, "cancelled": [[list, no], ...] in processing order, "new": [[list, no], ...] ordered by wave, then list, then no, "backordered": [[order, sku, units], ...] one entry per need that was not fully covered, in processing order}.
["order_status", order_id] returns {"status", "picked": [[sku, units], ...], "backordered": [[sku, units], ...]}, both sorted by SKU, listing only SKUs with units > 0 (order not found). Status is "open" (not released, including held orders), "released" (it has pending tasks) or "complete" (released and nothing pending, backordered units or not). picked totals the units taken by picked and short tasks.
Example (the Part 1 world and release, then):
[["confirm", "W1", 1, 3], ["order_status", "O1"], ["order_status", "O2"]]returns
[{"status": "short", "picked": 3, "short": 1, "cancelled": [["W1", 2]], "new": [["W1", 5]],
"backordered": [["O2", "TEA", 1]]},
{"status": "released", "picked": [["TEA", 3]], "backordered": []},
{"status": "released", "picked": [], "backordered": [["TEA", 1]]}]Task 1 took 3 of 4 units: T1's bin A-02-08-1 is written off, which cancels O2's pending task 2. The needs are O1's 1 missing unit, then O2's 1 cancelled unit. Lot T2 had 1 unit left after its reservation for task 4, so O1 gets it (new task 5 at A-03-02-1, packed into O1's W1-T3) and O2 is backordered.
---
Part 3 — Zone picking (WMS-4580, requirements change)
Goal: zone B's aisles are too long for one person to walk with everything else. Operations wants to run chosen zones as zone picking: each such zone gets its own picker who only picks that zone, into totes that are merged at the packing station. The single walking route of Parts 1 and 2 no longer holds for those zones.
["zone_mode", zone, on] turns zone picking for a zone on or off and returns the sorted list of zone letters now zone-picked (invalid zone if zone is not one capital letter; invalid arguments if on is not a boolean). It applies to waves released afterwards: each wave keeps the set of zone-picked zones it was released with, for everything that ever happens to it, including picks created later by a short pick. All earlier behaviour is unchanged for a wave released while no zone is zone-picked.
For a wave released with a non-empty set of zone-picked zones:
- Planning, allocation and the walk (including aisle parity) are unchanged.
- A task whose bin is in a zone-picked zone goes to that zone's list, named
<wave>-<zone>(e.g.W1-B), with picker label"zone B". Every other task stays in the wave's main listW1("roam"), whose walk now simply skips the zone-picked zones. A list with no tasks does not exist, soW1can be missing from a wave (pick_listandconfirmthen answerlist not found). - Every list is numbered from 1 and has its own totes, named
<list id>-T<n>(W1-T1,W1-B-T1). A tote never mixes lists. Rule 8 applies per list. listsin a wave lists the main list first, then the zone lists in letter order.- An order with totes on more than one list needs consolidation.
consolidateholds{"order": id, "totes": [tote ids]}for each such order, in the wave's order. An order's totes are listed by list (main first, then zone letters), and within a list in the order they were opened.["consolidation", wave_id]returns the same thing computed from the wave as it is now, including totes opened by later short-pick tasks (wave not found).wms/views.pyalready does this. - A short pick (Part 2) works across lists: a need that is re-allocated into a zone-picked zone of its wave becomes a task on that zone's list (created if it did not exist), anywhere else on the main list. Needs, cancellation order and the result format are as in Part 2; a cancelled task's reference is
[list, no]as usual, andnewis ordered by wave, then list (main first, then zone letters), thenno.
Example
[["sku", "TEA", 400, false, 0], ["sku", "JAM", 300, false, 0], ["carrier", "UPS", 600],
["bin", "A-01-02-1", "TEA", "face"], ["bin", "B-02-04-1", "JAM", "face"], ["bin", "B-03-01-1", "JAM", "face"],
["receive", "A-01-02-1", "T1", 10, null], ["receive", "B-02-04-1", "J1", 2, null],
["receive", "B-03-01-1", "J2", 5, null],
["zone_mode", "B", true],
["order", "O1", "UPS", [["TEA", 2], ["JAM", 3]]], ["order", "O2", "UPS", [["JAM", 1]]],
["order", "O3", "UPS", [["TEA", 1]]],
["release", 10], ["confirm", "W1-B", 1, 1]]zone_mode returns ["B"]. The release gives one wave W1 with two lists:
| list | no | loc | order | qty | totes |
|---|---|---|---|---|---|
W1 (roam) | 1 | A-01-02-1 | O1 | 2 | W1-T1 ×2 |
W1 (roam) | 2 | A-01-02-1 | O3 | 1 | W1-T2 ×1 |
W1-B (zone B) | 1 | B-02-04-1 | O1 | 2 | W1-B-T1 ×2 |
W1-B (zone B) | 2 | B-03-01-1 | O1 | 1 | W1-B-T1 ×1 |
W1-B (zone B) | 3 | B-03-01-1 | O2 | 1 | W1-B-T2 ×1 |
with consolidate [{"order": "O1", "totes": ["W1-T1", "W1-B-T1"]}]. Aisle 2 comes before aisle 3 in zone B. Confirming W1-B stop 1 with 1 unit is short: nothing else is pending in bin B-02-04-1, and O1's missing unit is allocated from lot J2, giving {"status": "short", "picked": 1, "short": 1, "cancelled": [], "new": [["W1-B", 4]], "backordered": []}.
---
Part 4 — Peak day (WMS-4612)
Goal: the pre-holiday release has tens of thousands of order lines and the floor confirms them in bulk. It has to run in seconds, with exactly the same results as Parts 1-3.
- Compact release.
["release", today, "summary"](any other third argument,nullincluded, isinvalid arguments, and that check comes beforeinvalid day) does the same work asreleasebut returns, per wave,wms/views.py'swave_summary:{"wave", "cutoff", "orders": <count>, "held": <count>, "lists": [{"list", "picker", "picks": <count>, "totes": <count>, "digest"}], "consolidate": <count>}. Thedigestislist_digest(list), a CRC-32 of every task's number, bin, SKU, lot, order, quantity and totes, so it changes if any of those differ. - Bulk confirm.
["confirm_all", list_id]or["confirm_all", list_id, physical]confirms the list's pending tasks in order ofno, each through exactly the same rules asconfirm.physicalis an optional object{bin location: units physically in that bin}. A task in a bin named there takesmin(qty, units still in the bin)and then reduces that bin's remaining units by what it took; a bin that is not named always has enough. Picking fewer thanqtyis a short pick, with every Part 2 consequence. Tasks that a short pick appends to this same list are confirmed by the same call, after the tasks already there; tasks that land on other lists are left alone. It returns{"confirmed": <full picks>, "short": <short picks>, "cancelled": <tasks cancelled>, "new": <tasks created>, "backordered_units": <units>}. Errors:list not found, theninvalid argumentsifphysicalis not an object mapping strings to integers>= 0. - Hidden tests release over 10,000 order lines in one run (generated with
fill_zoneandorder_series) and confirm them withconfirm_all, including short bins. The whole suite has a few seconds. The existing allocation and inventory code was written for small days: measure before you assume where the time goes, and keep the results identical.
Example
[["sku", "TEA", 400, false, 0], ["carrier", "UPS", 600],
["fill_zone", "A", 2, 2, 1, ["TEA"], 6],
["order_series", "S", 3, "UPS", ["TEA"], 1, 5],
["release", 10, "summary"],
["confirm_all", "W1", {"A-01-01-1": 1}],
["order_status", "S00001"]]fill_zone makes four bins (A-01-01-1, A-01-02-1, A-02-01-1, A-02-02-1) holding lots LA0 to LA3 of 6 TEA each, and order_series places S00001, S00002, S00003 for 2, 3 and 4 units. The three non-bulk results are
[3,
[{"wave": "W1", "cutoff": 600, "orders": 3, "held": 0,
"lists": [{"list": "W1", "picker": "roam", "picks": 4, "totes": 3, "digest": 1968790097}],
"consolidate": 0}],
{"confirmed": 5, "short": 1, "cancelled": 2, "new": 4, "backordered_units": 0}](for order_series, release and confirm_all), and every order ends "complete". The release has four tasks: three in bin A-01-01-1 (2, 3 and the first 1 unit of S00003) and one in A-01-02-1. confirm_all finds 1 unit in A-01-01-1: task 1 is short, tasks 2 and 3 are cancelled, and their 1 + 3 + 1 missing units become tasks 5 to 8, which the same call confirms.
Notes
- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones. Hidden tests re-check earlier behaviour after the later parts are in, and they mix operations from all four parts.
- Parts 1-3 is a strong result. Part 4 is a stretch.
Example 1
- Input:
- [...README world..., ["release", 10]]
- Output:
- one wave W1 with picks: A-02-08-1 TEA (O1 x4, O2 x1), A-02-03-1 MILK M2 (O1 x2, tote split), A-03-02-1 TEA T2 (O2 x2)
- Explanation
- MILK lot M2 expires on day 12 and still has the 2 shelf days MILK requires on day 10, so it is allocated before M1. Aisle 2 is even, so bay 08 is walked before bay 03. O1's second milk carton does not fit under 3000 g and opens tote W1-T3.
Example 2
- Input:
- [...same world..., ["release", 10], ["confirm", "W1", 1, 3]]
- Output:
- {"status": "short", "picked": 3, "short": 1, "cancelled": [["W1", 2]], "new": [["W1", 5]], "backordered": [["O2", "TEA", 1]]}
- Explanation
- The TEA bin was one unit short: it is written off, O2's pending pick there is cancelled, O1's missing unit is re-allocated first from the only other TEA lot, and O2's unit is backordered.
Example 3
- Input:
- [..., ["zone_mode", "B", true], ..., ["release", 10]]
- Output:
- wave W1 with lists W1 (roam) and W1-B (zone B) and consolidate [{"order": "O1", "totes": ["W1-T1", "W1-B-T1"]}]
- Explanation
- Zone B gets its own picker, numbering and totes; an order with totes on two lists is consolidated at packing.
Guidelines
- Python 3 standard library only
- Quantities are integer units, weights integer grams, days integer day numbers; there is no clock and no randomness
- Every operation returns exactly one value; failures are returned as {"error": "<code>"}, never raised, and change nothing
- Waves, pick lists, task numbers, tote ids and every report are fully deterministic (walk order, then order seq, line index and lot id)
- Part 4 inputs release and confirm tens of thousands of order lines in one run; the whole suite has a few seconds