Skip to content
V
For developers
Architecture, conventions, the core and security
Modules / Warehouse

Warehouse#

The Store module keeps track of quantities — it sells nothing itself. The e-shop tells it "reserve", "confirm", "release"; the warehouse answers and keeps documents about it. The code lives in app/UI/Api/Store/.

What the module is made of#

Entity Role
StockItem the stock card — linked to the e-shop's selling set
StockLevel the quantity in one warehouse: quantity and reserved
StockBatch a batch — number, expiry, purchase price
StockDocument a document (goods receipt, issue note, transfer, stocktake)
StockMovement + StockMovementBatch the movements and their split across batches
StockReservation a reservation
Warehouse, Supplier, Vacation code lists and warehouse closures
Service What for
StockReader the read model for availability and delivery estimates, no locks
StockRecalculator the nightly recount and self-heal
ReservationExpirer releasing expired reservations
MarginReporter margins from purchase and sale prices

Three quantities; what is sold is the available one#

quantity              physically in stock
reserved              held for orders
available = quantity − reserved

reserved is DENORMALISED — it is stored, not recomputed

A failure in the middle of an operation can leave a reservation without an order. The physical figure is then right, the available one is not — the goods are in stock but cannot be sold. Nothing reports it; it turns up as "why does it say sold out". Hence the nightly recount.

Reading availability is a constant number of queries

StockReader aggregates across warehouses, cards, suppliers and closures independently of the number of items, so a whole e-shop category listing can be resolved cheaply. Anyone computing availability item by item in a loop creates an N+1.

The interface for the e-shop: four operations#

The warehouse offers four methods outwards and all of them take a SourceRef — the triple "which module + what type + which id" (order 412 from the e-shop, say):

Method What it does
reserve($source, $lines, $allowPartial, $expiresAt) holds the quantity for that source
confirm($source, $salePrices, $userId) turns the reservation into an issue note
release($source) releases the reservation
post($documentId) posts a document and only thereby moves the quantity

SourceRef doubles as the idempotence key and the lock name (cms_store_src_<module>_<type>_<id>).

A repeated call with the same SourceRef is a no-op, not a second reservation

That is precisely why an order state transition can safely be repeated after a crash. Anyone inventing their own key loses that property.

The lock order is always e-shop → store

The e-shop holds the per-order lock, the warehouse adds its own over the source. The opposite order would deadlock under concurrency.

Procedure: connecting a new module to the warehouse#

  1. Introduce a SourceRef for your kind of document — module, type, id. It has to be stable across repetitions of the same operation.
  2. Reserve when the commitment arises (reserve()), with a validity matching how long that commitment lives.
  3. Decide whether you allow a partial reservation. The e-shop checkout does not (allowPartial = false) — a customer must not pay for goods that are not covered.
  4. On completion call confirm(), on cancellation release(). Never touch quantity directly.
  5. Click through the failure path too: what happens when the operation crashes between the reservation and the confirmation.

A direct UPDATE of the quantity bypasses the documents and the movements

The stock figure then adds up, but there is no way to find out why. Every change of quantity has to go through a document — otherwise a stocktake has nothing to compare against.

Documents#

Goods receipt, issue note, transfer, stocktake. Until a document is posted (post()) it does nothing to the quantities — it can be drafted, corrected and thrown away.

A posted document is neither corrected nor deleted

A reversal is issued and then a new document. Deleting would break the agreement between the current state and the movement history — and the discrepancy would only appear at stocktaking, with no trace of where it came from.

Movements are snapshots#

A movement stores the item's name and the balance at the moment of the movement.

Renaming a card does not change old movements

That is deliberate — a document should show what was on it at the time. The same logic as with an invoice.

Batches#

Written off oldest first. For goods with an expiry that also matches shelf life.

An expired batch does not remove itself

It stays in the records and gets written off normally. The /cron/store/batch/expiry-alert cron only warns; removing it is a manual issue note.

Reservations and concurrency#

A reservation arises with the commitment and has a validity; afterwards a cron releases it.

The reservation validity has to be aligned with the order expiry

If the reservation is released before the order expires, the goods can be sold a second time — and the second customer pays for something that is gone. The opposite mismatch is merely inefficiency: goods blocked longer than necessary.

The release cron runs every 5 minutes

/cron/store/reservation/expire. A longer interval means goods stay blocked past their expiry — and it shows up as "sold out", not as an error.

Stock operations are tied to the selling set (ProductSet), that is, to the very entity the e-shop sells.

Looking for a stock operation on a product or a variant finds nothing

The link only exists on the set. In detail: E-shop, the section on the data model.

Scheduled tasks#

Endpoint What it does Recommended interval
/cron/store/reservation/expire releases expired reservations every 5 min
/cron/store/batch/expiry-alert warns about approaching batch expiry every morning
/cron/store/stock/recount the nightly recount and self-heal of denormalised values nightly

Without the nightly recount a drift in reserved is never corrected

The denormalised value is only repaired by that run. A task that does not run produces no error — goods that are in stock and yet appear unavailable simply keep accumulating.

Where to look#

I want Where
the reservation interface app/UI/Api/Store/Models/Managers/StockReservationManager.php
posting documents app/UI/Api/Store/Models/Managers/StockDocumentManager.php
reading availability and estimates app/UI/Api/Store/Models/Services/StockReader.php
the recount and self-heal app/UI/Api/Store/Models/Services/StockRecalculator.php
the idempotence and lock key app/UI/Api/Store/Models/DTO/SourceRef.php
the cron tasks app/UI/Cron/Store/Presenters/

Follow-up chapters: E-shop · Invoicing · Scheduled tasks · Manager lifecycle