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 — 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#

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.

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#

  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.

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

  1. 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).
  2. copyToConcept() of the original document creates a draft with the same lines.
  3. 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:

ORDER BY (expiration_date IS NULL), expiration_date, received_datetime, stock_batch_id

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:

  1. GET_LOCK before beginTransaction() — ReservationService waits 5 s, the release cron does not wait at all and skips a busy document (the next run reconciles it),
  2. the work in one DBAL transaction; on a deadlock (SQLSTATE 40001 / 1213) up to three attempts,
  3. RELEASE_LOCK in finally.

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

  1. Put {control frontStoreVacationBanner} into every parts/*/header/breadcrumb.latte of the theme.
  2. Check that every header (parts/*/header/default.latte) and the @logged*.latte layouts end with an include of one of those partials.
  3. Do not add the component to the headers separately — the banner would show twice on the page.

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

  1. For a variant without a card call ProductVariantManager::foundStockItemCard($variantId). The variant must have a saved selling card (type = 1); otherwise it ends with VALIDATION_FAILED.
  2. Attach an existing card by saving the variant with ['stockItemId' => $id] in $meta.
  3. 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

Cookie settings