Skip to content

Putting stock in a bin

Four endpoints change what a bin holds. Everything else in the warehouse (receiving, put-away, picking, stocktakes, transfers, returns) ends up calling one of them with a different reason attached.

All four live under /api/_action/p2lab-stockly/warehouse/stock/, authenticate like any admin API call, and are gated by the same privilege: p2lab_stockly.execute_stock_operations. All four answer {"success": true}.

POST warehouse/stock/add

Goods arrive. There is no source; this is where stock enters the building.

{
"warehouseId": "0191f1…",
"binLocationId": "0191f2…",
"productId": "0191f3…",
"quantity": 12,
"comment": "Received without a purchase order",
"movementType": "add",
"batchNumber": "L-2411",
"expiresAt": "2027-03-31T00:00:00+00:00",
"handlingUnitCode": "LP-000241"
}
FieldRequiredNotes
warehouseIdyes
productIdyes
quantityyesa positive number; direction comes from the endpoint, never from the sign
binLocationIdnoomit and the goods land on the warehouse’s unassigned bin
commentnofree text, shown in the history
movementTypenoadd, return or correction
batchNumber, expiresAtnotogether they identify the lot the units join
handlingUnitCodenothe license plate the goods are placed on

POST warehouse/stock/remove

Goods leave. There is no target.

{
"warehouseId": "0191f1…",
"binLocationId": "0191f2…",
"productId": "0191f3…",
"quantity": 3,
"movementType": "damage",
"comment": "Crushed in handling",
"batchId": "0191f4…",
"cleanupEmptySource": false,
"handlingUnitId": "0191f5…",
"placementScoped": true
}
FieldRequiredNotes
warehouseId, productId, quantityyesas above
binLocationIdnoomitted means the unassigned bin, not “any bin”
movementTypenoremove, damage, loss or correction
batchIdnodraw from this lot instead of letting the removal strategy choose
cleanupEmptySourcenodrop the row when the removal empties it
handlingUnitId + placementScopednodraw from exactly this license plate

Without a batchId the units are consumed lot by lot in the order the warehouse’s removal strategy dictates, and the ledger gets one row per lot touched: a removal spanning three lots is three movements, not one.

placementScoped is what separates “the operator picked the loose units” from “the operator did not say”. With handlingUnitId set and placementScoped: true the draw-down hits that license plate and no other; without it, the first placement found is used, which may be the wrong one when the same lot sits both loose and on a plate in the same bin.

POST warehouse/stock/move

Goods travel. Both ends exist, the warehouse total does not change.

{
"sourceWarehouseId": "0191f1…",
"sourceBinLocationId": "0191f2…",
"targetWarehouseId": "0191f1…",
"targetBinLocationId": "0191f6…",
"productId": "0191f3…",
"quantity": 6,
"movementType": "putaway",
"purchaseOrderItemId": "0191f7…",
"sourceHandlingUnitId": "0191f5…",
"sourcePlacementScoped": true,
"targetHandlingUnitCode": "LP-000242",
"cleanupEmptySource": true
}
FieldRequiredNotes
sourceWarehouseId, targetWarehouseId, productId, quantityyes
sourceBinLocationId, targetBinLocationIdnoeither may be omitted for the unassigned bin
movementTypenomove, mapping, putaway or putaway_undo
batchIdnomove this lot specifically
operationIdnoties the movement to a warehouse operation
purchaseOrderId, purchaseOrderItemIdnoa put-away against a receipt
sourceHandlingUnitId + sourcePlacementScopednowhich plate the units come off
targetHandlingUnitCodenothe plate they land on
cleanupEmptySourcenodrop the source row when it empties

Moving the whole content of a license plate within one warehouse relocates the plate; moving part of it breaks the plate and the remainder stays behind. Naming a targetHandlingUnitCode is how loose stock gets palletised by a move.

A move that names a purchaseOrderId or purchaseOrderItemId also re-derives that receipt’s put-away progress. Plain bin-to-bin moves carry no such reference and change nothing outside the warehouse.

Source and target may not be the same location, including an unassigned-to-unassigned move, because both ends resolve to the bin before the check.

POST warehouse/stock/map

Places existing unassigned stock onto a real bin in the same warehouse, recorded as a mapping movement.

{
"warehouseId": "0191f1…",
"productId": "0191f3…",
"targetBinLocationId": "0191f6…",
"comment": "Location mapping",
"batchId": "0191f4…"
}

This is not a move with a nicer name. A move relocates goods that were somewhere; a mapping records for the first time where goods have been all along, which is why it is the operation the onboarding wizard uses. When the target bin is free the row keeps its identity and simply gains a bin; when the product already occupies that bin the two rows merge.

Cross-warehouse relocation is a move. This endpoint only works inside one warehouse.

An unknown movementType degrades, it does not fail. Each endpoint accepts its own short list and silently substitutes its default for anything else: add for the inbound endpoint, remove for the outbound one, move for the third. The call succeeds, the ledger records the wrong reason, and every report that groups by type is quietly wrong. There is no error to catch; the value sent has to be checked.

Quantity is always positive. All three endpoints reject zero and negatives with P2LAB_STOCKLY__STOCK__INVALID_QUANTITY (HTTP 400). Direction lives in which endpoint was called. A negative move would drain the target and inflate the source, the exact opposite of the intent.

Omitting the bin is a decision, not a blank. binLocationId: null means the unassigned bin every time: on the way in, on the way out, on both ends of a move. It never means “wherever this product happens to be”.

The transit bin is closed. Naming it as source or target is refused with P2LAB_STOCKLY__STOCK__TRANSIT_SOURCE_NOT_ALLOWED / …_TARGET_NOT_ALLOWED (HTTP 409). Goods enter it when a transfer ships and leave when that transfer arrives or is cancelled. Use the transfer endpoints instead.

Removing more than the shelf holds is refused, with P2LAB_STOCKLY__STOCK__INSUFFICIENT_STOCK (HTTP 422) naming what was requested and what was available; the quantity is not clamped to zero without notice.

A malformed request is a 400 that names the field. Missing or invalid ids and non-numeric quantities answer P2LAB_STOCKLY__REQUEST__INVALID with a message saying which field, before anything is written.

A warehouse can require goods to travel on a license plate. Where that policy is on, an add with no handlingUnitCode is refused, and so is a move landing loose at the target, unless the move carries a whole plate over from within the same warehouse, which satisfies the policy by itself. The rule is enforced on the server, so an integration that omits the code does not quietly bypass it.

Plates have structural operations of their own, all under the same privilege and all stock-neutral: the goods stay in the bin, only their carrier changes:

MethodPathWhat it does
POSThandling-unit/palletizeloose units in a bin go onto a plate
POSThandling-unit/depalletizethe reverse
POSThandling-unit/{huId}/nesta plate is loaded onto another one
POSThandling-unit/{huId}/unnesttaken off again
POSThandling-unit/{huId}/movethe whole plate goes to another bin
GEThandling-unit/{huId}/contentswhat is on it
GEThandling-unit/generate-codea free code to label a new plate with

A plugin in the same installation can call StockService directly. The methods mirror the endpoints (addStock(), removeStock(), moveStock(), mapStock()) and take the caller’s own Context.

Two things the endpoints handle and a direct caller must think about:

atomic() runs several primitives as one unit of work, for cases where a half-applied pair would be worse than a failure: stamping a verdict and moving the goods, booking a stocktake line and placing it. Database work only: a deadlock replays the callable from the start, so anything with an effect outside the database must stay out of it.

lockProductStockRows() is the first statement of every mutating unit, and the endpoints already issue it. It is what turns the availability check into a locked read; without it two concurrent draws both see the same number and the ledger records more units leaving than the shelf ever held.

In order, inside one transaction:

  1. the quantity row moves, and the batch split with it;
  2. one ledger row per lot touched;
  3. the lot placements and license-plate mirror follow;
  4. product.stock is recalculated from the shelves;
  5. on a removal, if the shelf fell below what orders had claimed, the earmark gives way and the difference becomes a backorder.

Facts are published after the commit: p2lab_stockly.stock.moved for the movement itself, and p2lab_stockly.stock.availability_changed when the recalculation actually changed what can be sold. Both are described in the event catalogue.

Before any of it, BeforeStockOperationEvent is dispatched outside the transaction, and a listener may refuse the whole operation. See policy hooks.