CoolCMS API

OrderStockManager
in package
uses SourceLockTransactionTrait

FinalYes

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
phpstan-import-type

TOrderGraph from OrderCalculator

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

Properties

Methods

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
Result

hasActiveReservation()

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
bool

release()

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
Result

releaseDeletedOrder()

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
Result

rereserve()

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
Result

reserve()

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
Result

acquireSourceLock()

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
bool

releaseSourceLock()

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
template
Return values
T

alreadyConfirmedResult()

Stock already issued by an issue document: no Store call and no column change (see `reserve()`, `rereserve()`).

private alreadyConfirmedResult() : Result
Return values
Result

applyOutcome()

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-string

ldiv()

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-string

lmul()

private lmul(string $a, string $b) : numeric-string
Parameters
$a : string
$b : string
Return values
numeric-string

lockName()

private lockName(int $orderId) : string
Parameters
$orderId : int
Return values
string

lsub()

private lsub(string $a, string $b) : numeric-string
Parameters
$a : string
$b : string
Return values
numeric-string

orderRowExists()

private orderRowExists(int $orderId) : bool
Parameters
$orderId : int
Return values
bool

rejectNestedTransaction()

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|null

reserveErrorMessage()

private reserveErrorMessage(int $orderId, string|null $errorCode) : string
Parameters
$orderId : int
$errorCode : string|null
Return values
string

resolveStockState()

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
string

settingBool()

private settingBool(string $key, bool $default) : bool
Parameters
$key : string
$default : bool
Return values
bool

syncOrderColumns()

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|null

writeOrderColumns()

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>

Order entity property => value

On this page

Search results