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#
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#
- Introduce a
SourceReffor your kind of document — module, type, id. It has to be stable across repetitions of the same operation. - Reserve when the commitment arises (
reserve()), with a validity matching how long that commitment lives. - 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. - On completion call
confirm(), on cancellationrelease(). Never touchquantitydirectly. - 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.
The link to the e-shop#
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