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 — the e-shop product variant points to it (ProductVariant::$stockItem) |
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.
Quantities and prices: three decimal places, half-up#
Stock quantities are decimal(10,3): quantity and reserved of a stock level, batches,
movements, movement-to-batch pairings, reservations and the card's min_quantity. Movement
prices (unit_price, unit_price_base, cogs_unit_price) have three decimal places as well.
Quantities in the e-shop (cart, order and complaint items) stay whole numbers.
DocumentService rounds every quantity and price from the caller once, at the entry of the
operation, half-up (away from zero) to 3 places (roundQuantity(), roundPrice()). The
availability check, the level update, the batch consumption and the movement row then all work
with the same rounded number; 1.0009 is stored as 1.001, the same as a draft saved from the
administration. Positivity is judged on the stored value: 0.0004 is zero and is refused,
0.0005 passes as 0.001.
| Input | What the service does |
|---|---|
text, exponent (1e2), a number outside the column range |
InvalidArgumentException, nothing is written (in a stocktake only the line is refused, reason "invalid number") |
| negative price | refused (toBase() guards every priced path: receipt, issue, reservation confirm, draft posting) |
price 0 |
a valid price, distinct from a missing one (null) |
level after a receipt or transfer outside decimal(10,3) |
refuses the operation before writing the level |
Shape and range are measured by ColumnValue::fitsDecimal() on the StockMovement mapping, the
same as the administration forms (ColumnGate::decimal()). Send a value from a field with the
Form::FLOAT rule to the warehouse as a string via Decimal::fromFloat(), not
(string) $float: 0.00001 would otherwise go out as 1.0E-5 and the mask rejects it.
Quantities are displayed with the Latte filter |quantity ('1.500' → "1.5", '5.000' → "5",
see Latte), in administration grids with
QuantityRenderer (CSV gets the machine form with a full stop) and in PHP with
Quantity::format().
Delivery days and closures#
StockReader computes deliveryDays (in how many days the goods ship) deterministically:
| Step | Rule |
|---|---|
| 1. shift | D_shift starts today; while it falls into an active closure it jumps to the closure's end_date + 1 (consecutive windows are rescanned) |
| 2. warehouse with stock | every active warehouse with available > 0: D_w = D_shift when it has vacation_affected, otherwise today; ship_w = D_w + the warehouse's delivery_days |
| 3. result | with stock, shipping = min(ship_w); otherwise D_shift + the delivery_days of the item's supplier; without a supplier null ("on request") |
Warehouse::$vacationAffected (column vacation_affected, default 1) says whether the
global closure applies to the warehouse. A warehouse with 0 keeps shipping during the
closure. A closure shifts the shipping day, not the transit: a window starting only
after the shipping day does not change the estimate. labelWarehouseId is the first
warehouse by order with stock — it labels the source, not the speed.
The minimum is taken over shipping dates, not over delivery_days
With a mix of warehouses with and without vacation_affected, the warehouse with the
shorter delivery_days can actually be the slower one. Picking the fastest warehouse
by delivery_days shows the customer a date that the closure will push back.
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.
Draft and posting#
The document form in the administration (StockDocumentFormTrait) saves only a draft as an
object graph with an owned collection of movements. The Api StockDocumentManager::beforeSave()
always forces the status to draft and refuses to edit or delete anything but a draft (it reads
the status with SELECT … FOR UPDATE). Only post() changes quantities; the "Save and post"
button saves the draft and calls post() through the bridge right away.
| Type | Line in the draft | What post() does |
|---|---|---|
| receipt | +q |
receives; refuses a non-positive quantity |
| issue | −q |
takes the absolute value and writes off from batches, or from the batch chosen on the line (selected_batch_id) |
| transfer | −q from the source warehouse |
refuses a positive line; creates +q on the target warehouse for the source line |
| stocktake | the difference | writes the difference |
Before writing anything, post() checks all lines: a line with zero quantity (and a positive
line in a transfer) refuses the whole document. In a receipt and an issue it also refuses a line
whose price is not a non-negative number within the column range (typically a negative price
saved before the form started guarding it). It throws InvalidDraftLineException with the reason
invalidUnitPrice, the line number (insertion order, as the form shows it through
StockDocument::getMovementsInLineOrder()), the card label CODE — Name and the value. The
document stays a draft; fix it and post again.
A positive line in a transfer draft would double the transfer
Typically it is a copy of a posted transfer. Posting would run it as a target-to-target move
and corrupt the movement journal. That is why post() refuses it and a transfer draft may
hold only −q.
Stocktake in the administration#
The stock-document/inventory screen (StockInventoryTrait) shows the items of the chosen
warehouse with the current quantity and a "counted" field and sends the lines to adjust():
| Input | What happens |
|---|---|
| empty field | the item is skipped (partial stocktake); it never means counted 0 |
a number with an exponent or outside the range of stock_levels.quantity |
the line is skipped already in the administration, the rest pass, the admin gets a warning |
| the quantity changed since rendering (hidden snapshot ≠ locked state) | adjust() rejects the line ("recount"), the rest pass |
| counted = stock | no movement |
| shortage / surplus | a shortage is written off from batches, a surplus creates a number-less batch at the average price |
The counts are read from the raw POST (counted[id], snapshot[id]), not from Nette controls:
the number of items on a page varies and the form holds only CSRF, the note and the button.
Counted values and snapshots must not pass through is_numeric() alone
It lets 1e21 through, on which bcadd() fails with ValueError. The exception brings
down the whole adjust() transaction, i.e. the whole stocktake batch, not one line. Shape
and range are measured by ColumnValue::isDecimalShape() and fitsDecimal() over
StockLevel::quantity.
Reversal#
DocumentService::cancel() accepts only a posted document. It issues a counter document of the
same type with negated quantities (source_type = 'storno', the source carried over from the
original), switches the original to cancelled and links both through storno_document_id. After
the reversal the cards get their average and last purchase price recomputed, so a card does not
keep the price of a cancelled delivery.
| Type | What the reversal does | When it is refused |
|---|---|---|
| issue note | returns the quantities to the original batches by the original pairing | — |
| goods receipt | writes off only the batches founded by this very receipt | part of the delivery has already been issued or transferred; or available stock would drop below zero and negative stock is not allowed |
| transfer | runs the transfer in the opposite direction | available stock in the target warehouse is less than the transferred quantity and negative stock is not allowed |
| stocktake | writes the opposite differences | the batch from a stocktake surplus has already been issued (even partly); or available stock would drop below zero and negative stock is not allowed |
| counter document (storno) | — | always: a reversal of a reversal is not allowed; a wrong reversal is fixed by entering the original operation again |
A posted document is corrected with copyToConcept(): it copies the header and the movements into
a new draft (no number or status; a storno becomes manual), which is edited and posted.
Procedure: correcting a wrongly posted document
- Call
cancel()on the original document and check that it went through (for a receipt it fails once goods from the delivery have been sold). copyToConcept()of the original document creates a draft with the same lines.- Fix the draft and post it with
post().
Errors of stock operations#
The administration and the e-shop call the operations over the internal bridge
(internalActionReceive(), internalActionCancel() …). The external HTTP actions of the same presenter
(actionReceive(), actionSave() …) always return PERMISSION_DENIED; from outside the
warehouse is read-only. An exception from an operation is converted by StoreFailure:
| Exception | error |
message |
|---|---|---|
InsufficientStockException |
insufficientStock |
the exception text (meant for the operator) |
ReservationShortfallException |
reservationShortfall |
the exception text |
InvalidDraftLineException |
invalidDraftLine |
the exception text; plus reason and params (line, item, value), from which the administration translates the message form.messages.post.<reason> with the line number and the card |
anything else (RuntimeException, DBAL, TypeError …) |
storeOperationFailed |
a generic sentence with a short ref; the full text only in log/store.log |
The refusal reason of a RuntimeException never reaches the operator
Most checks in DocumentService (reversal of a reversal, consumed delivery, document in
another status) throw RuntimeException. The admin then sees only "Store operation failed.
Reference: …" and the reason has to be looked up in the log by ref. To give the operator a
message, throw a domain exception from the Models/Exceptions/ folder and add it to
StoreFailure.
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 by FEFO (first expired, first out) with FIFO as the fallback order. Under the level
lock, batches with quantity > 0 are read in the order:
Batches without an expiry come last. Every consumed batch gets a pairing
(stock_movement_batches) and the movement carries cogs_unit_price as the weighted average
price of the consumed batches. An issue line with a chosen batch (IssueLine.batchId) takes
only from it, and the batch must belong to the same card and warehouse.
The oversell check is switched off by the setting stock.negative.allowed (module 10). With 1
the stock level may drop below zero: what the batches do not cover stays unpaired and is valued
at the last purchase price. After every operation the invariant sum of batches = max(level, 0)
is checked. A receipt onto a negative level therefore first repays the debt and founds a batch
only from the part above zero (max(0, old + q) − max(0, old)).
An expired batch does not remove itself
It stays in the records and FEFO writes it off first. The
/cron/store/batch/expiry-alert cron only warns; removing it is a manual issue
note.
The expiry watch (StockBatchManager::watchExpiry()) selects live batches expiring within
stock.batch.expiryAlertDays days (default 30), including those already expired (negative
daysLeft). It writes a hit to the store log; the e-mail (template ST_BATCH_EXPIRY_ALERT)
goes only to a valid address in stock.lowStock.email — the same one the low-stock watch uses.
Without an address it only logs.
The address from the settings has no language or localization of its own. Both e-mails
(ST_BATCH_EXPIRY_ALERT and the summary of cards below the minimum from
StockItemManager::watchLowStock()) are therefore rendered in the default website language
and numbers get the separator of the default localization: the manager pre-formats the
quantity with Quantity::format() ("1,5" in Czech, "1.5" in English) and the render runs inside
PriceFormatContext::runWith(), so |quantity and |priceFormat in the e-mail template use
the same localization. The template only prints the finished string, e.g. {$item['available']}.
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 validity is set in ReservationService::resolveExpires(): an explicit $expiresAt, else
NOW() + reservation.ttlMinutes from the database; with 0 the reservation never expires. The
application runs in Europe/Prague time, the database session in UTC, and expiry compares
expires_datetime < NOW() in the database. Pass $expiresAt as a DateTimeImmutable in
application time; this single place converts it to UTC.
Writing expires_datetime yourself shifts the validity by one to two hours
Whoever writes the time outside resolveExpires() (plain SQL or a format without the
conversion) stores Prague time into a UTC column. The reservation then expires 1–2 h later
than it should and the goods stay blocked.
Confirming a reservation#
confirm($source, $salePrices, $userId) turns the active reservations of a source into one posted
issue note (number VYD-…) and switches the reservations to confirmed, all in one transaction
under the source lock. The issue note has no warehouse in its header — the reservation may have
drawn from several warehouses and the truth is in the movements. The result is a ConfirmResult:
| Result | Meaning |
|---|---|
success, alreadyConfirmed = false |
the issue note was created now (documentId, number) |
success, alreadyConfirmed = true |
the source already has an issue note; the existing one is returned unchanged |
errorCode = 'noActiveReservations' |
nothing to issue (reservations missing or just expired) — the warehouse issues nothing |
errorCode = 'sourceLocked' |
the source lock could not be acquired within 5 s |
At most one posted issue note per source is also enforced by the database
(uq_stock_documents_confirm). Reservations that have lapsed at the moment of confirmation are
expired under the lock and do not go into the issue note.
warnings carries what went through but is worth knowing. Each warning is a code, either a
plain string (noActiveReservations) or a structured StockWarning {code, params}, never a
finished sentence. The card parameters come from StockWarning::itemParams(): itemId,
itemCode, itemName and the label item in the form CODE — Name (#id when there is
neither). The consumer translates <prefix>.<code> with the parameters in the current language;
the order status change in the administration has the keys statusForm.warnings.<code> for it.
| Code | When |
|---|---|
salePriceMissing |
the card was issued without a sale price — the issue note has the costs but not the revenue; the margin report leaves such an issue out of revenue, costs and margin and shows only their count |
salePriceInvalid (+ value) |
the given sale price is not a non-negative number in plain notation and is dropped; a price of 0 is valid and passes |
issuedWithoutBatchCoverage (+ warehouseId, unpaired, quantity) |
the physical stock dropped below the reservation meanwhile (for example by a stocktake shortage) — the full quantity is issued, the stock level goes negative and the uncovered part is valued at the last purchase price; the negative-stock setting does not apply here, the goods were reserved |
ReservationService adds the card label with one query for all warnings of a confirm. Whoever
passes warnings on keeps the structured shape too (StockWarning::parse()); a filter on strings
only would lose the card.
Do not handle noActiveReservations with a silent issue
The error means nothing is held for the order any more. The caller has to decide: reserve again, or issue manually with a stock check. Whoever ignores the error ships goods that may have been sold to another customer in the meantime.
Writes over one document are serialized by the advisory lock SourceRef::lockName()
(cms_store_src_<module>_<type>_<id>) from the shared SourceLockTransactionTrait. The
e-shop, invoicing and the company catalogue use it too. The order is fixed:
GET_LOCKbeforebeginTransaction()—ReservationServicewaits 5 s, the release cron does not wait at all and skips a busy document (the next run reconciles it),- the work in one DBAL transaction; on a deadlock (
SQLSTATE40001 / 1213) up to three attempts, RELEASE_LOCKinfinally.
The lock survives a rollback and the deadlock retry, so every attempt runs under the same serialization.
The lock and the transaction must run on the same connection
GET_LOCK is a MySQL session lock. On another connection it would not protect the
transaction and two writes of the same document would interleave. That is why the trait
requires a single connection() accessor for both.
Warehouse order#
The warehouse order column is the draw priority; warehouses are a flat list, not a tree. The
arrows in the list (WarehousePresenter::actionMoveUp() / actionMoveDown()) find the
neighbour in the order order ASC, id ASC and send one call to api/store/warehouse/swap-order.
In one transaction it locks both rows (FOR UPDATE), checks that order matches what the admin
saw in the list and writes both values. If they changed meanwhile, it writes nothing and returns
INVALID_STATE.
Swapping with two updateColumns() calls leaves the order broken
They are two separate requests: if the second fails, the first change stays, and two
concurrent moves end with a duplicate order.
Deleting a warehouse and a card#
| What | Delete refused | Why |
|---|---|---|
| warehouse | WAREHOUSE_DELETE_BLOCKED when it holds quantity or reservations, or any document or movement points at it |
levels and reservations are ON DELETE CASCADE (live stock would vanish), documents SET NULL (posted documents would lose their warehouse) |
| card | when it has a positive level, a reservation or batches | levels and batches are ON DELETE CASCADE |
So a warehouse with any history can never be deleted. It is retired by switching it off
(activated = 0): reservations stop drawing from it and it no longer counts towards delivery
estimates.
The card code is unique. StockItemManager::beforeSave() checks it with a raw query
(DB_DUPLICATE_ENTRY); a concurrent insert is caught by the uq_stock_items_code index as
DB_ERROR.
Margin#
MarginReporter::report($from, $to, $stockItemId) (bridge internalActionMarginReport()) computes over
the movements of posted documents by posted_datetime in the half-open interval
[from 00:00, the day after "to" 00:00), so the last day counts in full. The administration
sends only valid dates in the form YYYY-MM-DD; a non-existent day or "from" after "to" is
refused with a message and the report is not called.
| Line | From |
|---|---|
| revenue | −q × unit_price_base of issue notes in posted status, without counter documents (source_type = 'storno'), only movements with a sale price |
| costs (COGS) | −q × cogs_unit_price of the same movements |
| margin | revenue − costs |
| issues without a price | unpricedMovementCount: movements without unit_price_base, left out of the three lines above |
| cost estimated | costEstimatedMovementCount and costEstimated: priced movements that consumed a batch without a purchase price |
| stocktake differences | posted stocktakes: quantity = sum of q, value = sum of q × price (shortage negative, surplus positive) |
Each stocktake movement carries its price in a different column: a shortage has
cogs_unit_price (FEFO consumption of batches) and an empty unit_price_base, a surplus has the
price of the new batch (average, otherwise last purchase price) in unit_price_base and an empty
cogs_unit_price. The value therefore uses COALESCE(cogs_unit_price, unit_price_base).
A cancelled document (issue note and stocktake alike) has the cancelled status and its counter
document (source_type = 'storno') is excluded by the filter, so it disappears from the report
entirely. A cancelled stocktake therefore adds nothing to the stocktake differences. Transfers are
not counted, even though they carry −q with a price.
OrderStockManager builds the sale price of an order's issue note after discounts: the line
discount and the customer discount are subtracted per row, a voucher is split between the rows in
proportion to their price (the last row takes the rounding remainder). Revenue in the report thus
matches the goods price of the order without VAT, shipping and payment. A receipt without a price
values the batch at the card's average (otherwise last) purchase price; a batch stays without a
price only for a card that has no purchase price yet. An issue costs such a batch at the average of the priced batches of the same issue (none priced = cost 0),
so the report flags such movements as having an estimated cost.
Vacation banner#
The "shipping from …" message is computed only by StockReader::getVacationNotice(); the front
end takes it over the bridge api/store/vacation/banner (VacationRepository::fetchBanner()).
It returns closures that run today or start within $daysBefore days, and for each:
| Field | Value |
|---|---|
shipsFrom |
end_date + 1, moved past consecutive closures (the same loop as the delivery estimate) |
deliveryDays |
the shortest delivery_days of active warehouses with vacation_affected = 1; null without such a warehouse |
deliveryEstimate |
shipsFrom + deliveryDays, or null |
It is a global message, not the availability of a particular card. Dates go out as Y-m-d
strings.
The front end must not repeat the calculation
Its own "active vacation" filter or a date computed in the template drifts apart from the delivery-estimate algorithm (midnight normalisation, chaining of windows), and the banner then promises a different date than the cart shows.
The component takes the lead time from the Store setting vacation.banner.leadDays (module
10, the store settings in the administration). A missing or non-positive value means 3 days —
the setting is optional.
Where the banner is rendered#
The banner belongs to the Store module but shows across the whole website. The
frontStoreVacationBanner component sits in the theme's breadcrumb partials
parts/{shared,eshop,blog,bazaar,comcat,discussion}/header/breadcrumb.latte: every module
header ends with its own partial and @logged.latte / @logged_form.latte include the blog
one, so "My orders" and "Complaints" are covered too.
After the Api response the component loads the entities of the selected closures with one
findAll() with include(['translations']), skips a closure without an active translation in
the page language and sorts them by the Api order (findAll() guarantees none). It composes
and translates the message sentence in PHP and formats the dates by the page localisation.
Without deliveryEstimate the message gives only the shipping date.
A closure without an active translation does not show on the website
getTranslation() does not filter the active_translation flag, so the component checks
it itself and leaves such a closure out. A vacation created only in Czech therefore has no
banner on the English version of the site — the customer learns about it only from the
delivery date in the cart.
Procedure: the banner in a new theme
- Put
{control frontStoreVacationBanner}into everyparts/*/header/breadcrumb.latteof the theme. - Check that every header (
parts/*/header/default.latte) and the@logged*.lattelayouts end with an include of one of those partials. - Do not add the component to the headers separately — the banner would show twice on the page.
The link to the e-shop#
A stock card belongs to a product variant: the foreign key is held by
ProductVariant::$stockItem (column cms_mod_store_stock_items_stock_item_id in
product_variants), and it is the only source of truth. Orders and complaints work with sets;
SetCompositionService decomposes them into cards (a variant's selling card → its stock card,
a bundle → the cards of its leaves). In detail: E-shop.
| Rule | Where |
|---|---|
one card belongs to at most one variant; a second mapping is rejected with DB_DUPLICATE_ENTRY |
ProductVariantManager::beforeSave(), raw SELECT |
the mapping changes only through the $meta['stockItemId'] key on a variant save: missing key = unchanged, null or 0 = unmap, nonexistent card = ITEM_NOT_FOUND |
ProductVariantManager::save() |
the card carries a reverse mirror external_module = 4, external_id = variant id; only the variant's afterSave() writes and clears it |
cms_mod_store_stock_items |
a new card for a variant is created only by foundStockItemCard(): code and unit from the PRODUCT set, name from its text; a card with the same code is reused |
ProductVariantManager |
deleting a variant releases the card (deletes the mirror); PRODUCT_DELETE_BLOCKED rejects it when an order, complaint or gift points to the selling card, or the stock card is in an order |
ProductVariantManager::beforeDelete() / afterDelete() |
Procedure: linking a variant to a card from code
- For a variant without a card call
ProductVariantManager::foundStockItemCard($variantId). The variant must have a saved selling card (type = 1); otherwise it ends withVALIDATION_FAILED. - Attach an existing card by saving the variant with
['stockItemId' => $id]in$meta. - Do not write the
external_*mirror by hand; saving the variant aligns it.
The mirror on the card is not the link
external_module / external_id is only a helper so the stock module can find the
card's owner, and they are #[ExternalReadonly] fields. Whoever reads or changes the link
through them drifts apart from the real foreign key on the variant; on a collision the stock
module only logs AMBIGUOUS.
Looking for a stock operation on a product finds nothing
A product has no card, only its variants do. Documents and reservations are tied to the
card and to the source (SourceRef, an order for example).
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 |
/cron/store/stock/low-stock-alert |
summary e-mail about cards below the minimum (once per card, low_stock_notified_at) |
every morning |
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.
What the nightly recount repairs and what it only reports#
/cron/store/stock/recount calls StockRecalculator (through StockLevelManager::recountLevels()
and StockItemManager::recountItemPrices()). It always walks the whole table page by page
(stock.recount.batchSize, 200 by default) and fixes each row in its own short transaction.
The source of truth is the movement journal of non-draft documents.
| Value | What the recount does |
|---|---|
stock_levels.quantity |
recomputes it from the sum of movements and repairs a difference |
stock_levels.reserved |
recomputes it from active reservations and repairs a difference |
a missing stock_levels row for a card with batches or movements |
creates it again and logs it as a bug |
| average and last purchase price of the card | recomputes them and repairs a difference |
| batch total against the stock level | only logs — a batch cannot be repaired safely, its original quantity is not stored |
| balance stored in a movement | only logs — movements are never changed |
active reservations older than reservation.alert.staleDays (30 days by default) |
only logs |
Alarms only go to the store log
Nobody is notified. A drifted batch or a forgotten reservation shows up only as stock
that does not add up — read log/store.log after the nightly run.
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 |
| posting and stocktake | app/UI/Api/Store/Models/Services/DocumentService.php → post(), adjust() |
| the document form and stocktake in the administration | app/UI/Admin/Store/Presenters/Traits/Forms/StockDocumentFormTrait.php, Traits/StockInventoryTrait.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 |
| confirming a reservation and its validity | app/UI/Api/Store/Models/Services/ReservationService.php → confirm(), resolveExpires() |
| the operation response on failure | app/UI/Api/Store/Presenters/Traits/StoreFailure.php |
| the margin | app/UI/Api/Store/Models/Services/MarginReporter.php |
| the vacation banner on the website | app/UI/Front/Store/Components/VacationBannerComponent/VacationBanner.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