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"}| Field | Required | Notes |
|---|---|---|
warehouseId | yes | |
productId | yes | |
quantity | yes | a positive number; direction comes from the endpoint, never from the sign |
binLocationId | no | omit and the goods land on the warehouse’s unassigned bin |
comment | no | free text, shown in the history |
movementType | no | add, return or correction |
batchNumber, expiresAt | no | together they identify the lot the units join |
handlingUnitCode | no | the license plate the goods are placed on |
Remove
Section titled “Remove”
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}| Field | Required | Notes |
|---|---|---|
warehouseId, productId, quantity | yes | as above |
binLocationId | no | omitted means the unassigned bin, not “any bin” |
movementType | no | remove, damage, loss or correction |
batchId | no | draw from this lot instead of letting the removal strategy choose |
cleanupEmptySource | no | drop the row when the removal empties it |
handlingUnitId + placementScoped | no | draw 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}| Field | Required | Notes |
|---|---|---|
sourceWarehouseId, targetWarehouseId, productId, quantity | yes | |
sourceBinLocationId, targetBinLocationId | no | either may be omitted for the unassigned bin |
movementType | no | move, mapping, putaway or putaway_undo |
batchId | no | move this lot specifically |
operationId | no | ties the movement to a warehouse operation |
purchaseOrderId, purchaseOrderItemId | no | a put-away against a receipt |
sourceHandlingUnitId + sourcePlacementScoped | no | which plate the units come off |
targetHandlingUnitCode | no | the plate they land on |
cleanupEmptySource | no | drop 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.
Six most common errors
Section titled “Six most common errors”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.
License plates
Section titled “License plates”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:
| Method | Path | What it does |
|---|---|---|
POST | handling-unit/palletize | loose units in a bin go onto a plate |
POST | handling-unit/depalletize | the reverse |
POST | handling-unit/{huId}/nest | a plate is loaded onto another one |
POST | handling-unit/{huId}/unnest | taken off again |
POST | handling-unit/{huId}/move | the whole plate goes to another bin |
GET | handling-unit/{huId}/contents | what is on it |
GET | handling-unit/generate-code | a free code to label a new plate with |
In-process, without HTTP
Section titled “In-process, without HTTP”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.
What happens after the write
Section titled “What happens after the write”In order, inside one transaction:
- the quantity row moves, and the batch split with it;
- one ledger row per lot touched;
- the lot placements and license-plate mirror follow;
product.stockis recalculated from the shelves;- 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.
- Corrections and bulk jobs — counting, seeding, draining a warehouse
- Reading stock — asking before writing
- The stock model — what these rows are