Skip to content

Slotting and Replenishment

theGreenGuy edited this page Jun 15, 2026 · 19 revisions

Slotting & Replenishment

How openWCS decides where received stock goes and keeps pick faces stocked. This is the inbound counterpart to Outbound Flow. Authoritative detail: docs/adr/0003-slotting-and-replenishment.md and docs/AS-BUILT.md.

The slotting service (port 8093, /api/slotting/**) owns this. It serves two worlds:

1. Automated rack / goods-to-person (ASRS, AutoStore, AMR-GTP)

A SKU is slotted to a storage block — the whole pool of locations across all aisles — not a fixed location. The put-away engine chooses the actual location per handling unit at put-away time, reconciling four competing objectives with one weighted score (per-block weights in block_policy):

  • Velocity-to-exit — fast movers (class A) near the aisle port, slow movers (C) deep (location.distance_to_exit; when it is not maintained, slotting derives a rank-equivalent distance from the cell coordinate as (posX−1)+(posY−1)+(posZ−1), assuming the in/outfeed at position 1, ground level — the guided rack builder also stamps the value explicitly now).
  • Same-SKU lane consolidation — fill a partially-used multi-deep lane of the same SKU to avoid honeycombing / reshuffles (location.lane_depth).
  • Aisle redundancy — spread a SKU across aisles so an aisle/crane outage can't strand it and it can be picked in parallel.
  • Aisle fill balancing — prefer emptier aisles so travel/throughput stays even.

Hard constraints: lane capacity and a max-%-of-SKU-per-aisle cap (the cap + a min-aisle floor reconcile redundancy with consolidation; the cap is skipped in single-aisle blocks — no alternative aisle exists so the share is always 100%, and enforcing it would reject every SKU's second putaway in a one-aisle block). Single-SKU-per-lane is a soft preference — the lane-affinity term rewards a same-SKU lane and penalises mixing, but a higher balance weight can outweigh it when you want aisle balance to win.

Profile-less SKU fallback: a SKU with no storage profile resolves to the warehouse's only automated storage block (SHUTTLE_ASRS, CRANE_ASRS, AUTOSTORE, or AMR_GTP, looked up via GET /api/master-data/storage-blocks?warehouseId=). When several automated blocks exist, slotting returns 400 — a storage profile must be assigned to disambiguate. This keeps the return-to-storage leg answerable for demo SKUs and newly received items that haven't been profiled yet.

2. Manual pick faces (pick-by-light / racking)

A SKU+UoM is slotted to a specific location with min/max (pick_slot). Replenishment keeps it stocked:

  • Below-min (reactive): on-hand ≤ min raises a refill-to-max task — EMERGENCY if empty, else SCHEDULED.
  • Opportunistic top-off: an off-peak cron refills every face up to max (OPPORTUNISTIC).
  • Direct-to-pick: inbound is cross-docked straight to a forward face when it has headroom.

Re-slotting

On the off-peak cron, the engine re-runs the put-away scorer over a block's current contents and recommends moves when a materially better location exists (velocity drift, honeycombing, aisle imbalance) — gated by block_policy.reslot_shift_pct.

Dispatching plans as physical moves

Slotting now turns its PLANS into real device-task moves via flow-orchestrator. A FlowClient posts to POST /api/flow/moves (see Transport Overview), which dispatches a RELOCATE (AutoStore: BIN_RELOCATE) to physically relocate the HU; on completion flow books the HU's new location in inventory and the HandlingUnitMoved txlog audit fires. Three dispatch endpoints:

Endpoint What it does
POST /api/slotting/reslot/{id}/dispatch Dispatches a re-slot recommendation as a RELOCATE move (from the recommendation's current → recommended cell).
POST /api/slotting/replenishment/{taskId}/dispatch Resolves a source HU best-effort (FEFO-approx) and dispatches a move from that source to the pick face.
POST /api/slotting/decant/putaway {warehouseId, huId, skuId, qty} Runs the put-away scorer for a freshly decanted HU, then dispatches the STORE to the chosen cell — the goods-in decant → put-away step.
  • DISPATCHED status: a successfully dispatched plan flips to a new DISPATCHED status, and the resulting device_task_id is stamped on the row (migration V7 adds device_task_id to reslot_recommendation and replenishment_task).
  • Best-effort: a flow failure leaves the plan un-dispatched (the plan can be retried); the caller distinguishes flow-down from a real rejection.
  • Multi-leg: a same-system move is a single RELOCATE leg; a cross-system move (source and destination in different storage blocks) runs a full RETRIEVE → CONVEY → STORE chain in flow-orchestrator, with the STORE leg's device family resolved from the destination block (see Transport Overview).
  • Compose: slotting gained OPENWCS_FLOW_ORCHESTRATOR_BASE_URL so it can reach flow.

Lane-depth reconciliation

The slotting engine reasons about a multi-deep lane's occupied depth from its own ledger: the active put-away assignments (PLANNED / DISPATCHED) whose chosen cell sits in the lane. Over time that picture drifts from the physical truth: a store/move/pick that completed without its assignment being closed leaves a ghost marking a cell occupied that is actually empty, or a cell gets filled outside the engine.

POST /api/slotting/lanes/reconcile?warehouseId=&blockId?= (RBAC OPERATOR/ADMIN, warehouse-scoped; a single block when blockId is given, else all blocks) reconciles the ledger against reality. For every multi-deep lane (lane_depth > 1, cells grouped by aisle + vertical level + column) it:

  1. reads the engine's ledger (active assignments per cell);
  2. asks inventory which of those cells are actually occupied, reusing the POST /api/inventory/locations/occupied projection;
  3. reports per-lane drift as {blockId, laneKey, locationId (face cell), expectedDepth, actualDepth};
  4. corrects ghosts: where the ledger holds an assignment for a cell inventory reports empty, the assignment is closed (status RECONCILED) so the engine stops counting it occupied.

An off-peak ShedLock sweep (LaneReconciliationScheduler, cron openwcs.slotting.offpeak-cron, default 02:00) runs the same reconciliation across every warehouse. It is best-effort: if inventory occupancy is unavailable the lane is skipped and the pass degrades to an empty report rather than crashing.

Configuration & API

  • Slotting is configured in the UI ("Slotting" tab): assign pick faces (SKU+UoM, min/max, direct-to-pick) and block slotting (SKU → block, velocity class, redundancy knobs).
  • Velocity/ABC class is self-taught: the service consumes txlog.stream movements and keeps a per-SKU recency-weighted (EWMA) pick-frequency, then an off-peak job assigns A/B/C — unless a manual_override pins it. So new/seasonal SKUs ramp to A fast and decay.
  • Risers/fallers (dashboard): the velocity consumer also upserts one row per SKU per day into sku_pick_daily (V6 migration, idempotent). The ABC Movers dashboard reads this table to compute exact trailing-14d-vs-90d windowed pick rates and surfaces the biggest risers and fallers — a more precise signal than the EWMA short-vs-long proxy that preceded it. The EWMA still drives the main A/B/C class assignment.
  • Key endpoints: POST /api/slotting/putaway, POST /api/slotting/putaway/stored (store confirmation — see assignment lifecycle below), POST /api/slotting/replenishment/{plan,top-off}, GET …/replenishment/tasks, POST /api/slotting/reslot/recommend, POST /api/slotting/relocation-plan (dig-out plan for a blocked multi-deep retrieve — see below), plus CRUD for storage profiles / pick slots / block policies and master-data storage-blocks.
  • Assignment lifecycle: a fresh POST /api/slotting/putaway call supersedes any open (PLANNED/DISPATCHED) assignments for the same HU first — a tote can only travel to one slot at a time, so each workplace round-trip re-ask leaves the ledger bounded at one live plan per tote. Once the ASRS STORE completes, flow-orchestrator calls POST /api/slotting/putaway/stored {warehouseId, huId} (best-effort, like the HU location booking); the assignment moves to STORED status and the cell appears as physically occupied via the inventory check rather than as planned occupancy. Response: {"closed": N}.
  • Block-policy scorer-weight field names (PUT/GET /api/slotting/block-policies/{blockId}): wVelocity, wConsolidation, wRedundancy, wBalance — all lowercase-w camelCase. Java's bean-naming convention would otherwise capitalise the leading pair (WVelocity), so @JsonProperty pins the lowercase contract; use exactly these names in API calls and the UI — mismatched capitalisation silently leaves weights at their default of 1.
  • A goods-in BPMN assignPutaway delegate calls the engine and sets the target location for the downstream move.
  • HTTP error codes: 400 — invalid input (missing or malformed fields); 404 — referenced entity not found; 409 — infeasible placement (e.g. no feasible storage location in block, no storage profile for SKU and multiple automated blocks exist). Callers that treat slotting as best-effort (e.g. flow-orchestrator's return-to-storage leg) should distinguish 409 from 5xx: 409 means the request was understood but physically unsatisfiable and will not succeed on retry; 5xx means a transient fault (flow leaves the tote circulating on the conveyor and retries via InductionSlotSweeper). Prior to f1b9603, infeasible placements returned 500.

Observability

Every material decision in the slotting service is logged at INFO (or WARN for degraded paths) so an engineer can trace a fault from the daily log alone without enabling DEBUG.

API rejections (logged by ApiExceptionHandler before the HTTP response is sent — useful when the caller is best-effort and only surfaces a one-line warning on its own side):

Status Reason Level Log message pattern
400 Invalid input WARN request rejected (400): <message>
404 Entity not found WARN request rejected (404): <message>
409 Infeasible placement WARN request rejected (409): <message>

Put-away (logged immediately before the PutawayDecision is returned):

Decision path Log fields
Scored block placement hu, dominant SKU, chosen location, block, score, factor map
Direct-to-pick hu, SKU, pick-face location, headroom
Empty-HU placement hu, location, block, distance-to-exit, LOW priority
Open assignment superseded on re-assign assignment id, HU id, previous chosen location
Store confirmation received (STORED) assignment id, HU id, confirmed storage location

Replenishment:

Event Level Key fields
Task created INFO pick-face location, SKU, on-hand, min/max, refill qty, trigger type, priority
Dedup skip — open task exists for this face (below-min) WARN pick-face, SKU, on-hand, min threshold
Dedup skip — open task exists for this face (top-off) DEBUG pick-face

Re-slotting:

Event Level Key fields
Move recommended INFO HU, SKU, from/to location, block, score gain vs shift threshold
Per-run cap reached (200 recommendations) WARN block, cap limit, consequence (remaining HUs re-evaluated next off-peak pass)

Relocation planning (dig-out):

Event Level Key fields
Plan produced INFO source location, blocker count, each move as hu <code> <from> -> <to>
Unplannable — no free same-level target WARN source location, blocker cell Y, consequence (retrieve stays blocked)

Velocity / ABC reclassification:

Event Level Key fields
Class updated INFO SKU, old class → new class, block, decayed EWMA score
Manual-override skip DEBUG SKU, pinned class, block, learned class

Block-policy save:

Event Level Key fields
Policy created or updated INFO block, warehouse, all four scorer weights, reslot enabled/disabled, shift threshold, off-peak cron

Handling units & exact location

  • HU type capabilities (master-data → Handling unit types admin catalog): compartments (1–8), storable_in_automation, transportable_on_conveyor, plus L×W×H dimensions (mm), weight limit (g), and nestable flag. Full CRUD via Master Data → Handling unit types in the UI. Each storage block also has an allowed_hu_types list, so an automated area only accepts the HU types it permits (e.g. conveyable but not storable). In the UI this is configured as a chip multi-select of active (non-archived) HU type names — no free text — in both the standard block dialog and the new Guided builder (which also bulk-generates rack locations from aisle × levels × positions × sides).
  • Multi-compartment HUs (1–8 SKUs): the dominant compartment (most qty) drives velocity placement; lane affinity matches on the compartment SKU set (a single-SKU HU is a one-element set).
  • Empty HUs (no SKU) are stored farthest from the exit and moved at LOW transport priority — a buffer for decanting.
  • Cell-level locations: a storage location row is one cell, uniquely identified by (warehouse, aisle, side, x, y, z), so a handling unit's position is always exactly known; a multi-deep lane = the cells sharing (aisle, side, x, y) over z (z1 = face … zN = deepest). Conveyors/stations are locations too (a function group), so an HU in transit is located at its conveyor.
  • Live-occupancy filter: before scoring, the engine asks inventory which of the block's candidate locations physically hold any stock row or handling unit (POST /api/inventory/locations/occupied) and drops those, so a seeded/occupied slot with no slotting assignment is never chosen. The call is best-effort — if inventory is unreachable it is logged and skipped so put-away still proceeds.

Dig-out planning (multi-deep channels)

For a multi-deep lane, a retrieve at cell Z N is physically blocked by any HU occupying a lower Z in the same channel (aisle + side + posX + posY). POST /api/slotting/relocation-plan (ADR 0009 step 1) returns the ordered sequence of single-shuttle relocations the WCS must execute before the retrieve can proceed.

Request: { "warehouseId": "…", "locationId": "…" }locationId is the retrieve's source slot (the cell containing the target HU).

Response:

{
  "steps": [
    { "huId": "", "huCode": "TTE-001", "fromLocationId": "", "toLocationId": "" }
  ],
  "blocked": false
}
  • steps are ordered front-most (lowest posZ) first — the shuttle must clear the aisle face before it can reach deeper cells.
  • Target selection is subject to a hard same-level constraint: the target must share posY with the blocker (the shuttle serves one level; changing level requires an expensive lift move). Within that constraint, targets are ranked: same aisle → same side → shallow (posZ 1) → closest along the aisle (|ΔposX|) → location code (deterministic tiebreak). The channel being cleared is never a valid target; no target is reused within one plan.
  • blocked: true, steps: [] — a blocker exists but no valid same-level free slot was found; the caller should halt the retrieve and escalate rather than attempt it.
  • blocked: false, steps: [] — the channel is already clear; the retrieve can go immediately.

Occupancy is read from the live HU registry (GET /api/inventory/handling-units); cell coordinates from master-data. If the registry is unreachable the planner returns clear (fail-open) rather than acting on stale data.

Status (ADR 0009): planning endpoint ✅ (step 1); physical dig-out dispatch ✅ (step 2) — flow-orchestrator executes the RELOCATE/BIN_RELOCATE chain before every blocked retrieve (see Equipment Integration); stored-tote rack view ✅ (step 3 — see Hardware Visualisation).

Status: the engine computes plans, exposes them, and now dispatches them as physical moves (re-slot / replenishment / decant put-away → flow POST /api/flow/moves; plans flip to DISPATCHED; V7 stamps device_task_id). The physical-move audit foundation is in — flow-orchestrator appends a HandlingUnitMoved event to the txlog system-of-record on every completed/failed STORE/RETRIEVE/RELOCATE it executes (see Transport Overview). The multi-leg RETRIEVE → CONVEY → STORE move chain is now built (with destination-block-resolved STORE family), and lane-depth ledger↔inventory reconciliation is now built (POST /api/slotting/lanes/reconcile + an off-peak sweep, above). Still tracked fast-follows: HU on-conveyor booking and true (not approx) FEFO replenishment sourcing. See Roadmap and Status · Goods-to-Person Stations.

See Services · Architecture · Equipment Integration.

Clone this wiki locally