OrderStockManager
in package
uses
SourceLockTransactionTrait
Single entry point for an order's interaction with Store reservations and the issue document.
An order is a single SourceRef(4, 'order', $orderId); lines are aggregated from the frozen
snapshot OrderItem::$units, never from the live catalog. Store is called through the
StockReservationManager facade; order columns are written through
OrderManager::updateColumns() obtained from OrderManagerFactory (the factory
breaks the DI cycle). The settings 4/order.partial.adminAllowed and 4/payment.windowHours
come from SettingContext.
Lock: per-order GET_LOCK('cms_eshop_order_{id}') (5 s), reentrant within one MySQL
session, so calls nested from OrderTransitionManager::apply() are safe. Store has its own
SourceRef lock; the lock order is always e-shop first, then Store, which prevents
deadlocks. GET_LOCK/RELEASE_LOCK and the deadlock-retry transaction come from
SourceLockTransactionTrait.
Syncing order_items.not_in_stock stays a raw DBAL update (OrderItem has no manager and
an object-graph save of the order would endanger the collections through orphan removal).
IMPORTANT: the decision state is read from the DB, not from the entity
(resolveStockState()). An in-memory order may come from an object-graph save without
stockState (the hydrator keeps the default NONE), which would mask a real CONFIRMED
and allocate stock again for an already issued order. The authority is orders.stock_state
read under the lock by raw SELECT (never through OrderManager::find(), whose
clearDoctrine() would detach the caller's half-built graph); the entity is the fallback
only when the order row no longer exists. A deleted order is released through
releaseDeletedOrder() with the state captured before the delete, not through the entity.
IMPORTANT: no call inside a foreign transaction. Public methods refuse to work when a
transaction is active on the connection (nestedTransaction): runInSourceTransaction()
would become a mere savepoint of that transaction, so the stock write would not be durable
and a foreign rollback could silently discard it while Store committed its own session.
Callers (completedSave()/completedDelete() of OrderManager) run post-commit.
Tags
Table of Contents
Constants
- ITEMS_TABLE : string = 'cms_mod_eshop_order_items'
- LOCK_MAX_ATTEMPTS : int = 3
- LOCK_SCALE : int = 3
- LOCK_TIMEOUT : int = 5
- MODULE : int = 4
- ORDERS_TABLE : string = 'cms_mod_eshop_orders'
- SETTING_MODULE : int = 4
- SOURCE_TYPE : string = 'order'
Properties
- $em : EntityManagerInterface
- $orderManagerFactory : OrderManagerFactory
- $settingContext : SettingContext
- $stockReservationManager : StockReservationManager
Methods
- __construct() : mixed
- confirm() : Result
- Creates the issue document on shipping. Result data: `documentId`, `alreadyConfirmed` and `warnings`.
- hasActiveReservation() : bool
- Does Store still hold at least one active reservation row for the order? Read-only, no lock; a hint for the payment hook, while the decisive write is {@see rereserve()} under the lock.
- release() : Result
- Releases the reservation idempotently.
- releaseDeletedOrder() : Result
- Releases the reservation of an order whose row is already deleted (`OrderManager::completedDelete()`).
- rereserve() : Result
- Handles an item edit: release, then reserve the new lines under the e-shop lock (one outer and two nested; MySQL `GET_LOCK` is reentrant within one session).
- reserve() : Result
- Reserves the whole order as a single SourceRef with a single `reserve()`.
- acquireSourceLock() : bool
- Calls `GET_LOCK(name, timeout)` and returns true only on a definite 1 (0 = timed out or busy, null = error). `$timeout` is in seconds; 0 is non-blocking (try-lock, used by the expirer).
- connection() : Connection
- lockLogChannel() : string
- releaseSourceLock() : void
- Releases the advisory lock.
- runInSourceTransaction() : T
- Runs a closure in one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / errno 1213).
- alreadyConfirmedResult() : Result
- Stock already issued by an issue document: no Store call and no column change (see `reserve()`, `rereserve()`).
- applyOutcome() : void
- buildLines() : array<int, ReservationLine>
- Aggregates the units of all order items per stockItemId into the sum of quantity.
- buildSalePrices() : array<int, array{unitPrice: string, currencyId: ?int}>
- Splits the row base (item.price times quantity, including fees) over the units in proportion to their quantity (the last unit takes the remainder), then aggregates per stockItemId over all items into a weighted average (assigned base divided by pieces).
- distributeNotInStock() : array<int, int>
- Distributes the Store `notInStock` (stockItemId => uncovered) over the items, deterministically in ascending id order.
- ladd() : numeric-string
- lcmp() : int
- ldec() : numeric-string
- ldiv() : numeric-string
- Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).
- lInt() : int
- lmul() : numeric-string
- ln() : numeric-string
- lockName() : string
- lStr() : string
- lStrN() : string|null
- lsub() : numeric-string
- orderRowExists() : bool
- paymentWindowHours() : int
- rejectNestedTransaction() : Result|null
- Guards against a call inside a foreign open transaction: in DBAL 4 the source transaction would only create a savepoint, so a foreign rollback could silently discard the stock write while Store committed on its own. Called before the lock, so nothing is left locked.
- reserveErrorMessage() : string
- resolveStockState() : string
- Returns the decisive `stock_state` of the order under the lock.
- settingBool() : bool
- syncOrderColumns() : void
- syncOrderColumnsConfirmed() : void
- toUtcColumn() : string|null
- Converts the reservation expiry to a UTC column value.
- writeOrderColumns() : void
- Writes the order's own columns through {@see OrderManager::updateColumns()}, fetched from {@see OrderManagerFactory} only now to avoid a DI cycle.
Constants
ITEMS_TABLE
private
string
ITEMS_TABLE
= 'cms_mod_eshop_order_items'
LOCK_MAX_ATTEMPTS
private
int
LOCK_MAX_ATTEMPTS
= 3
LOCK_SCALE
private
int
LOCK_SCALE
= 3
LOCK_TIMEOUT
private
int
LOCK_TIMEOUT
= 5
MODULE
private
int
MODULE
= 4
ORDERS_TABLE
private
string
ORDERS_TABLE
= 'cms_mod_eshop_orders'
SETTING_MODULE
private
int
SETTING_MODULE
= 4
SOURCE_TYPE
private
string
SOURCE_TYPE
= 'order'
Properties
$em read-only
private
EntityManagerInterface
$em
$orderManagerFactory read-only
private
OrderManagerFactory
$orderManagerFactory
$settingContext read-only
private
SettingContext
$settingContext
$stockReservationManager read-only
private
StockReservationManager
$stockReservationManager
Methods
__construct()
public
__construct(EntityManagerInterface $em, StockReservationManager $stockReservationManager, OrderManagerFactory $orderManagerFactory, SettingContext $settingContext) : mixed
Parameters
- $em : EntityManagerInterface
- $stockReservationManager : StockReservationManager
- $orderManagerFactory : OrderManagerFactory
- $settingContext : SettingContext
confirm()
Creates the issue document on shipping. Result data: `documentId`, `alreadyConfirmed` and `warnings`.
public
confirm(Order $order[, int|null $userId = null ]) : Result
The error code noActiveReservations becomes a success with a warning so the transition
continues; it is never a silent oversell.
Parameters
- $order : Order
- $userId : int|null = null
Return values
ResulthasActiveReservation()
Does Store still hold at least one active reservation row for the order? Read-only, no lock; a hint for the payment hook, while the decisive write is {@see rereserve()} under the lock.
public
hasActiveReservation(int $orderId) : bool
Parameters
- $orderId : int
Return values
boolrelease()
Releases the reservation idempotently.
public
release(Order $order) : Result
After a confirm (stock_state=CONFIRMED) it is a no-op success (a cancellation after
shipping is handled by a stock return). For NONE/RELEASED Store is still called
(a no-op there) and the state does not change.
Parameters
- $order : Order
Return values
ResultreleaseDeletedOrder()
Releases the reservation of an order whose row is already deleted (`OrderManager::completedDelete()`).
public
releaseDeletedOrder(int $orderId, string $stockState) : Result
Works from scalars captured before the delete instead of the entity: Doctrine nulls the id of the
removed instance and there is no row left to read stock_state from or to write it back to. The
Store SourceRef survives the delete (Store has no FK to the e-shop), so the release itself is the
same idempotent Store call under the same per-order lock as release(). $stockState is the
state captured before the delete; CONFIRMED is a no-op success like in release().
IMPORTANT: refuses a still existing order (INVALID_STATE). For a live order the DB state is the
authority and the columns must be synced, which only release() does.
Parameters
- $orderId : int
- $stockState : string
Return values
Resultrereserve()
Handles an item edit: release, then reserve the new lines under the e-shop lock (one outer and two nested; MySQL `GET_LOCK` is reentrant within one session).
public
rereserve(Order $order[, bool|null $allowPartial = null ]) : Result
After shipping (stock_state=CONFIRMED in the DB) it is a no-op success with the warning
alreadyConfirmed: releasing and allocating again for an already issued order would
overwrite stock_state, drop the link to the issue document and cause an oversell.
Parameters
- $order : Order
- $allowPartial : bool|null = null
Return values
Resultreserve()
Reserves the whole order as a single SourceRef with a single `reserve()`.
public
reserve(Order $order[, bool|null $allowPartial = null ]) : Result
$allowPartial null means the setting 4/order.partial.adminAllowed. Empty lines (only
unmapped items) give NONE as a success without calling Store. Result data:
stockState, notInStock (itemId => quantity) and reservationExpiresAt.
An order already confirmed by an issue document (stock_state=CONFIRMED in the DB) is not
reserved again: success with the warning alreadyConfirmed and no Store call.
Parameters
- $order : Order
- $allowPartial : bool|null = null
Return values
ResultacquireSourceLock()
Calls `GET_LOCK(name, timeout)` and returns true only on a definite 1 (0 = timed out or busy, null = error). `$timeout` is in seconds; 0 is non-blocking (try-lock, used by the expirer).
protected
acquireSourceLock(string $name, int $timeout) : bool
Parameters
- $name : string
- $timeout : int
Return values
boolconnection()
protected
connection() : Connection
Return values
ConnectionlockLogChannel()
protected
lockLogChannel() : string
Return values
stringreleaseSourceLock()
Releases the advisory lock.
protected
releaseSourceLock(string $name) : void
IMPORTANT: it is called from a finally, so a failure here (a gone-away connection after a
fatal query) is swallowed and logged; rethrowing would mask the original exception being
unwound. MySQL drops session locks when the connection dies anyway, so nothing leaks past
the process.
Parameters
- $name : string
runInSourceTransaction()
Runs a closure in one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / errno 1213).
protected
runInSourceTransaction(callable(Connection): T $fn) : T
The advisory lock (held by the caller across this call) is not released by a rollback, so every attempt runs under the same serialization.
Parameters
- $fn : callable(Connection): T
Tags
Return values
TalreadyConfirmedResult()
Stock already issued by an issue document: no Store call and no column change (see `reserve()`, `rereserve()`).
private
alreadyConfirmedResult() : Result
Return values
ResultapplyOutcome()
private
applyOutcome(int $orderId, Order $order, string $stockState, DateTimeImmutable|null $expiresAt, array<int, int> $notInStockByItem) : void
Parameters
- $orderId : int
- $order : Order
- $stockState : string
- $expiresAt : DateTimeImmutable|null
- $notInStockByItem : array<int, int>
-
order_item_id => not_in_stock (mapped items only)
buildLines()
Aggregates the units of all order items per stockItemId into the sum of quantity.
private
buildLines(Order $order) : array<int, ReservationLine>
Units without a stockItemId (unmapped) are skipped; the builder already counted them in notInStock.
Parameters
- $order : Order
Return values
array<int, ReservationLine>buildSalePrices()
Splits the row base (item.price times quantity, including fees) over the units in proportion to their quantity (the last unit takes the remainder), then aggregates per stockItemId over all items into a weighted average (assigned base divided by pieces).
private
buildSalePrices(Order $order) : array<int, array{unitPrice: string, currencyId: ?int}>
Parameters
- $order : Order
Return values
array<int, array{unitPrice: string, currencyId: ?int}>distributeNotInStock()
Distributes the Store `notInStock` (stockItemId => uncovered) over the items, deterministically in ascending id order.
private
distributeNotInStock(Order $order, array<int, string> $shortfall) : array<int, int>
Items without units (unmapped variant or mixed bundle) are skipped; the builder holds their
notInStock. Pieces per set are unit.quantity / item.quantity (units carry the total
quantity); a non-integer result is rounded up as a defensive fallback.
Parameters
- $order : Order
- $shortfall : array<int, string>
-
stockItemId => uncovered quantity (from Store)
Return values
array<int, int> —order_item_id => pieces not covered by the reservation (mapped items only)
ladd()
private
ladd(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringlcmp()
private
lcmp(string $a, string $b) : int
Parameters
- $a : string
- $b : string
Return values
intldec()
private
ldec(string $value) : numeric-string
Parameters
- $value : string
Return values
numeric-stringldiv()
Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).
private
ldiv(string $a, string $b) : numeric-string
IMPORTANT: truncation would systematically drop up to 0.001 from every unit on large quantities, so the invariant sum(unitPrice x quantity) ~ sum(rowBase) would drift in one direction (an issue document undervalued against the order).
Parameters
- $a : string
- $b : string
Return values
numeric-stringlInt()
private
lInt(mixed $value) : int
Parameters
- $value : mixed
Return values
intlmul()
private
lmul(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringln()
private
ln(string $value) : numeric-string
Parameters
- $value : string
Return values
numeric-stringlockName()
private
lockName(int $orderId) : string
Parameters
- $orderId : int
Return values
stringlStr()
private
lStr(mixed $value) : string
Parameters
- $value : mixed
Return values
stringlStrN()
private
lStrN(mixed $value) : string|null
Parameters
- $value : mixed
Return values
string|nulllsub()
private
lsub(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringorderRowExists()
private
orderRowExists(int $orderId) : bool
Parameters
- $orderId : int
Return values
boolpaymentWindowHours()
private
paymentWindowHours() : int
Return values
intrejectNestedTransaction()
Guards against a call inside a foreign open transaction: in DBAL 4 the source transaction would only create a savepoint, so a foreign rollback could silently discard the stock write while Store committed on its own. Called before the lock, so nothing is left locked.
private
rejectNestedTransaction(string $operation, int $orderId) : Result|null
Parameters
- $operation : string
- $orderId : int
Return values
Result|nullreserveErrorMessage()
private
reserveErrorMessage(int $orderId, string|null $errorCode) : string
Parameters
- $orderId : int
- $errorCode : string|null
Return values
stringresolveStockState()
Returns the decisive `stock_state` of the order under the lock.
private
resolveStockState(int $orderId, TOrderGraph $order) : string
The DB is the authority, not the in-memory entity (an object-graph save without
stockState keeps the default NONE and would mask CONFIRMED). The entity is synced
too, so the caller keeps working consistently. When the order row is gone, the entity is the
fallback (a deleted order goes through releaseDeletedOrder() instead).
Parameters
- $orderId : int
- $order : TOrderGraph
Return values
stringsettingBool()
private
settingBool(string $key, bool $default) : bool
Parameters
- $key : string
- $default : bool
Return values
boolsyncOrderColumns()
private
syncOrderColumns(int $orderId, string $stockState, DateTimeImmutable|null $expiresAt) : void
Parameters
- $orderId : int
- $stockState : string
- $expiresAt : DateTimeImmutable|null
syncOrderColumnsConfirmed()
private
syncOrderColumnsConfirmed(int $orderId, int|null $documentId) : void
Parameters
- $orderId : int
- $documentId : int|null
toUtcColumn()
Converts the reservation expiry to a UTC column value.
private
toUtcColumn(DateTimeImmutable|null $expiresAt) : string|null
IMPORTANT: $expiresAt is a Prague-time DateTimeImmutable, but the cron
(OrderCronTrait::expireDueOrderIds())
compares reservation_expires_at with the DB-side < NOW() in a UTC session. Storing it
unchanged would be 1-2 hours ahead and unpaid orders would be canceled late.
setTimezone() returns a new instance, so the parameter stays in Prague time for display.
Parameters
- $expiresAt : DateTimeImmutable|null
Return values
string|nullwriteOrderColumns()
Writes the order's own columns through {@see OrderManager::updateColumns()}, fetched from {@see OrderManagerFactory} only now to avoid a DI cycle.
private
writeOrderColumns(int $orderId, array<string, mixed> $values) : void
updateColumns() runs no save hooks and uses the same DBAL connection, so inside
runInSourceTransaction() it is atomic with the order_items.not_in_stock write.
IMPORTANT: the repository swallows a DBAL exception and returns Result::failure, which
inside a transaction would be a silent partial write; hence a failure is converted back
to an exception (rollback and propagation).
Parameters
- $orderId : int
- $values : array<string, mixed>
-
Orderentity property => value