CoolCMS API

OrderTransitionManager
in package
uses SourceLockTransactionTrait

FinalYes

Single entry point for order status changes: the admin status form, the payment hook and crons always call this, never their own UPDATE. `apply()` is the only public method.

The whole transition runs under a per-order advisory lock cms_eshop_order_{id} (GET_LOCK, 5 s, released in finally) that serializes concurrent applies and edits. The lock is reentrant within one session, so nested OrderStockManager calls (same lock) are safe; the lock order is always e-shop, then store.

Idempotence layers, so that a repeated apply after a crash cannot produce a second set of effects:

  1. per-order lock,
  2. store SourceRef idempotence (re-reserve is a no-op, confirm returns the existing posted issue document, release is a no-op),
  3. invoice lookup by (orderId, invoiceTypeId) with multipleUse=false returns the existing invoice,
  4. points gate UPDATE orders SET user_is_updated=1 WHERE ... AND user_is_updated=0 in the same mini transaction as crediting,
  5. status, voucher counters, order_payments, invoice markPaid and e-mails in one final DBAL transaction (transactional outbox: e-mails are rendered before it, only the INSERT runs inside).

IMPORTANT: state is written only through OrderManager::updateColumns() with a dynamic SET list (changed columns only), never an object-graph save(), which would touch order collections through orphanRemoval and collide with the UoW. updateColumns() runs no save hooks and uses the same DBAL connection, so it stays inside the final transaction. The manager comes from OrderManagerFactory, because direct injection would close a DI cycle (OrderManager, OrderTransitionManager, OrderManager).

IMPORTANT: loyalty points are credited in their own mini transaction before the final one, not inside it. If the process dies between them, points are credited but the status is unchanged; a repeated apply catches the status up and does not credit points again (gate affected=0).

Known limits:

  • generateVoucher (VoucherGenerator) is fail-soft without a watermark: a missing level or a DB error only returns a warning (voucherLevelNotFound/voucherGenerationFailed), and unlike the invoice the cron does not retry it,
  • e-mails are never sent synchronously, only queued in cms_system_email_queue.
Tags
phpstan-import-type

TOrderGraph from OrderCalculator

phpstan-type

TOrderStatusGraph OrderStatus

Table of Contents

Constants

EXPIRE_DUE_PREDICATE  : string = 'o.`is_cancelled` = 0 AND o.`reservation_expire...
Single definition of "order is due for expiry" (alias `o` = orders, `s` = their current status).
ORDER_INCLUDES  : array<string|int, mixed> = [ 'items.units', 'items.fees', 'vouchers.vouche...
Includes for the fresh load of the order under the lock: the whole graph the transition effects need.
UNPAID_CANCEL_DUE_PREDICATE  : string = 'o.`is_cancelled` = 0 AND s.`key` = \'' . self:...
Single definition of "unpaid order is due for cancellation" (alias `o` = orders, `s` = their current status; one `?` = cutoff of `insert_datetime`, computed by PHP because the column is written in PHP time and the DB runs in UTC). Shared by the cron selection ({@see \App\UI\Api\Eshop\Models\Managers\Traits\OrderCronTrait::cancelDueUnpaidOrderIds()}) and the check under the lock in {@see transition()} (opt `cancelUnpaidCreatedBefore`).
UNPAID_CANCEL_STATUS_KEY  : string = 'WAITING-ON-PAYMENT'
Key of the awaiting-payment status whose orders the unpaid-cancel cron takes.
LOCK_MAX_ATTEMPTS  : int = 3
LOCK_SCALE  : int = 3
LOCK_TIMEOUT  : int = 5
MONEY_RECEIVED_PAYMENT_STATUSES  : array<string|int, mixed> = [\App\UI\Api\System\Enums\PaymentStatus::COMPLE...
Payment statuses meaning money reached the merchant (fully, refunded, or with a wrong amount).
ORDER_PAYMENTS_TABLE  : string = 'cms_mod_eshop_order_payments'
ORDERS_TABLE  : string = 'cms_mod_eshop_orders'
SHIPPERS_TABLE  : string = 'cms_mod_eshop_shippers'
STATUSES_TABLE  : string = 'cms_mod_eshop_order_statuses'
VOUCHER_FAILURE_SIGNAL  : string = 'voucherIncrementFailed:'
Message prefix of the `DomainException` the final transaction closure throws for an exhausted voucher.
VOUCHERS_TABLE  : string = 'cms_mod_eshop_vouchers'

Properties

$em  : EntityManagerInterface
$emailGenerator  : OrderEmailGenerator
$emailQueueManager  : EmailQueueManager
$invoiceGenerator  : OrderInvoiceGenerator
$orderManagerFactory  : OrderManagerFactory
$orderStatusManager  : OrderStatusManager
$orderTaxManager  : OrderTaxManager
$paymentManager  : PaymentManager
$paymentRegionService  : OrderPaymentRegionService
$stockManager  : OrderStockManager
$userManager  : UserManager
$voucherGenerator  : VoucherGenerator
$voucherManager  : VoucherManager

Methods

__construct()  : mixed
apply()  : Result
Changes the order status with all effects of the target status (cancel or restore of stock and vouchers, dispatch, issue document, payment pairing, invoice, e-mails, loyalty points).
enqueueCurrentStatusEmails()  : int
Queues the e-mail(s) of the current status without a transition, for an order that stays in its initial status after creation (`CREATED`, cash on delivery without `4/order.approval.auto`).
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).
absolutePdfPath()  : string|null
Invoicer stores `pdf` as a relative path (`/www/private/...`); the e-mail attachment needs an absolute one.
applyInMemory()  : void
Applies the intents (or, after a failed transaction, the snapshot back) to the in-memory entity, which the caller keeps using (admin form, checkout).
checkExpeditionShipper()  : Result|null
Checks that the shipper from `opts.expedition.shipperId` exists: a pre-flight copy of the guard in {@see collectExpedition()} (which stays there as a TOCTOU safety). Nothing is checked without the key or with `null` (a dispatch without a shipper is legal).
checkGating()  : Result|null
Checks the allowed transition: from a status that does not allow canceling one cannot go to a canceling status; from a canceled status only to a status that re-creates the order (`createOrder`). An order without a status (fresh insert) has nothing to block.
checkVoucherCapacity()  : Result|null
Pre-checks the capacity of vouchers the transition is about to increment (restore of a canceled order). It is the same guard as the atomic `UPDATE ... WHERE max_number = 0 OR current_number < max_number` in {@see VoucherManager::incrementUsage()}, only earlier, so the reject happens before the irreversible crediting of loyalty points.
collectExpedition()  : Result|null
Collects the dispatch columns from `$opts['expedition']` (the date defaults to now). Returns a `Result` only on error (nonexistent shipper), otherwise null with `$intents`/`$memory` filled.
creditUserPoints()  : void
Credits loyalty points: `floor(roundedTotalPriceWithVat / currency.creditPrice)`, falling back to `roundedTotalPrice` when the total with VAT is zero. The `user_is_updated` gate is in the same mini transaction as the crediting, so points are credited exactly once even on a repeated apply.
hasReceivedMoney()  : bool
Tells whether any payment linked to the order (`order_payments`, the module's own link) already brought money: status from the owner of `cms_system_payments` ({@see PaymentManager::getStatusRow()}).
isExpireDue()  : bool
Checks authoritatively from the DB (under the order lock) with the same predicate as the cron selection.
isUnpaidCancelDue()  : bool
Checks authoritatively from the DB (under the order lock) with the same predicate as the unpaid-cancel cron selection.
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
optInt()  : int|null
paymentExists()  : bool
Checks payment existence through the owner of `cms_system_payments` ({@see PaymentManager::exists()}), not a raw SELECT from the e-shop. Deliberately not `find()`: it hydrates the entity (needless) and `GetAllTrait::clearDoctrine()` in the chain could detach the already loaded order graph.
prepareEmails()  : array<int, PreparedOrderEmail>
Renders the transition e-mails before the final transaction, which then only inserts the ready rows ({@see EmailQueueManager::enqueue()}): the transactional outbox stays atomic with the status write while Latte and I/O hold no locks. An empty template, a missing address or a failing render returns null, so nothing is queued.
reconcileFromDb()  : void
Syncs the entity with the authoritative state of the decision columns from the DB (under the lock), protecting against a stale entity from the Doctrine identity map. `em->clear()` is never called here because callers keep references to the loaded graph.
rejectAfterStockEffects()  : Result
Compensates a rejected transition after step 5/6. The reservation made by a restore was committed by Store outside our transaction, so no rollback returns it. The compensation is needed on every reject path after step 6, not only in the catch of the final transaction, otherwise a rejected transition would leave a canceled order holding stock.
reRateTaxAtShipping()  : bool
Re-rates VAT when dispatching an unpaid order. Unpaid means no paired payment (`order_payments`): cash on delivery and invoices with a due date are paid only after dispatch. Fail-soft: a re-rate error does not fail the dispatch (logged, the original rate stays).
result()  : Result
shipperExists()  : bool
snapshotInMemory()  : array<string, mixed>
stringList()  : array<int, string>
Normalizes warnings from nested service Results (`getData()` is `mixed`) to a list of strings.
transition()  : Result
undoRenewReservation()  : void
Releases the reservation made by restoring a canceled order when the transition is rejected.
voucherIds()  : array<int, int>
Returns ids of vouchers applied to the order (only those linked to a live voucher; a snapshot without an FK has no counter to give back to).
writeOrderColumns()  : void
Writes the intents through {@see OrderManager::updateColumns()} with a dynamic SET list: only the columns the transition really changes (never a full object-graph save, which would endanger order collections through orphanRemoval). `$intents` keys are `Order` entity properties; the mapper translates them to columns (also FKs of ToOne associations such as `status`/`payment`/ `paymentRegion`/`expeditionShipper`, where the value is the id). `updateColumns()` runs no save hooks and uses the same connection, so it stays inside the caller's final transaction.

Constants

EXPIRE_DUE_PREDICATE

Single definition of "order is due for expiry" (alias `o` = orders, `s` = their current status).

public string EXPIRE_DUE_PREDICATE = 'o.`is_cancelled` = 0 AND o.`reservation_expires_at` IS NOT NULL' . ' AND o.`reservation_expires_at` < NOW() AND s.`allow_cancelling` = 1'

Shared by the cron selection (OrderCronTrait::expireDueOrderIds()) and the check under the lock in transition() (opt expireOnlyIfDue), so they cannot diverge.

ORDER_INCLUDES

Includes for the fresh load of the order under the lock: the whole graph the transition effects need.

public array<string|int, mixed> ORDER_INCLUDES = [ 'items.units', 'items.fees', 'vouchers.voucher', 'gifts', 'status.translations', 'currency', 'user', 'language', // Amount and date format in customer e-mails (`OrderEmailGenerator::inOrderFormat()`). 'localization', 'paymentRegion', 'shipper', 'paymentMethod', 'payments', 'expeditionShipper', ]

IMPORTANT: must contain every collection touched by anything run from apply(), notably OrderInvoiceGenerator::appendLines(). A collection left lazy would be initialized inside the invoice generator, after applySeller(), whose findAll() runs GetAllTrait::clearDoctrine() and detaches the whole identity map. The collection then hydrates with a detached owner and the invoice flush fails with "A new entity was found through the relationship", so the invoice is never issued. Eager includes hydrate the collection before anything can clear the identity map.

UNPAID_CANCEL_DUE_PREDICATE

Single definition of "unpaid order is due for cancellation" (alias `o` = orders, `s` = their current status; one `?` = cutoff of `insert_datetime`, computed by PHP because the column is written in PHP time and the DB runs in UTC). Shared by the cron selection ({@see \App\UI\Api\Eshop\Models\Managers\Traits\OrderCronTrait::cancelDueUnpaidOrderIds()}) and the check under the lock in {@see transition()} (opt `cancelUnpaidCreatedBefore`).

public string UNPAID_CANCEL_DUE_PREDICATE = 'o.`is_cancelled` = 0 AND s.`key` = \'' . self::UNPAID_CANCEL_STATUS_KEY . '\'' . ' AND o.`insert_datetime` < ?'

UNPAID_CANCEL_STATUS_KEY

Key of the awaiting-payment status whose orders the unpaid-cancel cron takes.

public string UNPAID_CANCEL_STATUS_KEY = 'WAITING-ON-PAYMENT'

MONEY_RECEIVED_PAYMENT_STATUSES

Payment statuses meaning money reached the merchant (fully, refunded, or with a wrong amount).

private array<string|int, mixed> MONEY_RECEIVED_PAYMENT_STATUSES = [\App\UI\Api\System\Enums\PaymentStatus::COMPLETED, \App\UI\Api\System\Enums\PaymentStatus::REFUNDED, \App\UI\Api\System\Enums\PaymentStatus::BAD_PRICE]

An order with such a linked payment is never cancelled for non-payment; the admin settles it.

VOUCHER_FAILURE_SIGNAL

Message prefix of the `DomainException` the final transaction closure throws for an exhausted voucher.

private string VOUCHER_FAILURE_SIGNAL = 'voucherIncrementFailed:'

Properties

Methods

__construct()

public __construct(EntityManagerInterface $em, OrderManagerFactory $orderManagerFactory, OrderStatusManager $orderStatusManager, VoucherManager $voucherManager, OrderStockManager $stockManager, OrderInvoiceGenerator $invoiceGenerator, OrderEmailGenerator $emailGenerator, OrderPaymentRegionService $paymentRegionService, VoucherGenerator $voucherGenerator, OrderTaxManager $orderTaxManager, PaymentManager $paymentManager, UserManager $userManager, EmailQueueManager $emailQueueManager) : mixed
Parameters
$em : EntityManagerInterface
$orderManagerFactory : OrderManagerFactory
$orderStatusManager : OrderStatusManager
$voucherManager : VoucherManager
$stockManager : OrderStockManager
$invoiceGenerator : OrderInvoiceGenerator
$emailGenerator : OrderEmailGenerator
$paymentRegionService : OrderPaymentRegionService
$voucherGenerator : VoucherGenerator
$orderTaxManager : OrderTaxManager
$paymentManager : PaymentManager
$userManager : UserManager
$emailQueueManager : EmailQueueManager

apply()

Changes the order status with all effects of the target status (cancel or restore of stock and vouchers, dispatch, issue document, payment pairing, invoice, e-mails, loyalty points).

public apply(int $orderId, int $newStatusId[, array<string, mixed> $opts = [] ]) : Result

$opts:

  • statusInformations (string): status note, stored in status_informations,
  • paymentId (int): paired System payment (only with status.pairPayment); optional because cash on delivery has none, but without it invoices are not marked paid and the warning paymentNotPaired is returned,
  • expedition (array{datetime?: DateTimeInterface, shipperId?: int, branchId?: int, code?: string, informations?: string}): only with status.fillExpedition,
  • sendStatusEmail / sendDefaultEmail (bool, default true): disable a specific e-mail,
  • sendExpeditionEmail (bool, default per Shipper::getSendEmail()),
  • generateInvoice (bool, default true): disables invoice issuing (parity with the admin checkbox),
  • allowPartial (bool, default false = strict): only for restoring a canceled order,
  • userId (int): who performed the transition (goes to the store issue document),
  • expireOnlyIfDue (bool, default false): cron path. After the fresh load under the lock and before any effect (store release, vouchers, status write) EXPIRE_DUE_PREDICATE is checked again. If the admin meanwhile moved, canceled or extended the order, a no-op success (noop=true) is returned, not an error. Manual transitions do not send it.
  • cancelUnpaidCreatedBefore (DateTimeInterface): cron path of the unpaid-order cancellation. Under the lock UNPAID_CANCEL_DUE_PREDICATE is checked with this cutoff (not due = no-op success); a linked payment with money received rejects with paymentReceived. Only then the gate "status does not allow cancelling" is lifted for this one transition (the awaiting-payment status has allow_cancelling=0, so the admin still cannot cancel it by hand); the target must cancel the order (cancelOrder=1), otherwise transitionNotAllowed.

Success data: ['noop'=>bool,'warnings'=>list<string>,'invoiceId'=>?int, 'stockDocumentId'=>?int]. Failure errorCode in data: nestedTransaction, orderLocked, orderNotFound, statusNotFound, transitionNotAllowed, renewReserveFailed, stockConfirmFailed, paymentNotFound, paymentReceived, shipperNotFound, voucherIncrementFailed, transactionFailed, transitionFailed (unexpected exception outside the final transaction; the method never throws, see the catch below).

IMPORTANT: never call inside an open transaction (the guard below rejects it as nestedTransaction). In DBAL 4 the final transaction would become only a savepoint of the outer one, so the e-mail outbox and the status write would lose durability (an outer rollback silently takes them) while Store and Invoicer already committed on their own. This applies to the payment hook too: the payment callback must commit its transaction first and call apply() after that.

Parameters
$orderId : int
$newStatusId : int
$opts : array<string, mixed> = []
Return values
Result

enqueueCurrentStatusEmails()

Queues the e-mail(s) of the current status without a transition, for an order that stays in its initial status after creation (`CREATED`, cash on delivery without `4/order.approval.auto`).

public enqueueCurrentStatusEmails(TOrderGraph $order, TOrderStatusGraph $status, string|null $invoicePdf[, string|null $invoiceVariableSymbol = null ]) : int

apply() to the same status is a no-op (step 3), so such an order would get no confirmation although the status has send_email=1 and its own text. Rendering and rules are the same as for a transition (prepareEmails(): the status's own text, ES_ORDER_CHANGED only as a fallback); the queue write runs in its own mini transaction. Fail-soft: returns the number of queued e-mails, an error is logged and returned as 0.

Parameters
$order : TOrderGraph
$status : TOrderStatusGraph
$invoicePdf : string|null

relative path of the invoice PDF (as the generator returns it), or null

$invoiceVariableSymbol : string|null = null
Return values
int

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

absolutePdfPath()

Invoicer stores `pdf` as a relative path (`/www/private/...`); the e-mail attachment needs an absolute one.

private absolutePdfPath(mixed $pdf) : string|null
Parameters
$pdf : mixed
Return values
string|null

applyInMemory()

Applies the intents (or, after a failed transaction, the snapshot back) to the in-memory entity, which the caller keeps using (admin form, checkout).

private applyInMemory(Order $order, array<string, mixed> $memory) : void
Parameters
$order : Order
$memory : array<string, mixed>

checkExpeditionShipper()

Checks that the shipper from `opts.expedition.shipperId` exists: a pre-flight copy of the guard in {@see collectExpedition()} (which stays there as a TOCTOU safety). Nothing is checked without the key or with `null` (a dispatch without a shipper is legal).

private checkExpeditionShipper(array<string, mixed> $opts, int $orderId) : Result|null
Parameters
$opts : array<string, mixed>
$orderId : int
Return values
Result|null

checkGating()

Checks the allowed transition: from a status that does not allow canceling one cannot go to a canceling status; from a canceled status only to a status that re-creates the order (`createOrder`). An order without a status (fresh insert) has nothing to block.

private checkGating(OrderStatus|null $current, OrderStatus $target[, bool $unpaidCancel = false ]) : Result|null

$unpaidCancel lifts only the first rule, for the cron cancellation of an unpaid order already verified as due under the lock (opt cancelUnpaidCreatedBefore).

Parameters
$current : OrderStatus|null
$target : OrderStatus
$unpaidCancel : bool = false
Return values
Result|null

checkVoucherCapacity()

Pre-checks the capacity of vouchers the transition is about to increment (restore of a canceled order). It is the same guard as the atomic `UPDATE ... WHERE max_number = 0 OR current_number < max_number` in {@see VoucherManager::incrementUsage()}, only earlier, so the reject happens before the irreversible crediting of loyalty points.

private checkVoucherCapacity(array<int, int> $voucherIds, int $orderId) : Result|null

NOTE: capacity can still vanish between this pre-check and the final transaction (another order uses the voucher up). Then only the in-transaction increment rejects and the points for the rejected transition stay credited; the window is just the short time between pre-check and commit. Removing it would need the points inside the final transaction, which is deliberately avoided: the user_is_updated gate must be committed even if the process dies, so points are not credited twice.

Parameters
$voucherIds : array<int, int>
$orderId : int
Return values
Result|null

collectExpedition()

Collects the dispatch columns from `$opts['expedition']` (the date defaults to now). Returns a `Result` only on error (nonexistent shipper), otherwise null with `$intents`/`$memory` filled.

private collectExpedition(Order $order, array<string, mixed> $opts, array<string, mixed> &$intents, array<string, mixed> &$memory) : Result|null
Parameters
$order : Order
$opts : array<string, mixed>
$intents : array<string, mixed>
$memory : array<string, mixed>
Return values
Result|null

creditUserPoints()

Credits loyalty points: `floor(roundedTotalPriceWithVat / currency.creditPrice)`, falling back to `roundedTotalPrice` when the total with VAT is zero. The `user_is_updated` gate is in the same mini transaction as the crediting, so points are credited exactly once even on a repeated apply.

private creditUserPoints(Order $order, int $orderId, array<int, string> &$warnings) : void
Parameters
$order : Order
$orderId : int
$warnings : array<int, string>

hasReceivedMoney()

Tells whether any payment linked to the order (`order_payments`, the module's own link) already brought money: status from the owner of `cms_system_payments` ({@see PaymentManager::getStatusRow()}).

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

isExpireDue()

Checks authoritatively from the DB (under the order lock) with the same predicate as the cron selection.

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

isUnpaidCancelDue()

Checks authoritatively from the DB (under the order lock) with the same predicate as the unpaid-cancel cron selection.

private isUnpaidCancelDue(int $orderId, DateTimeInterface $createdBefore) : bool
Parameters
$orderId : int
$createdBefore : DateTimeInterface
Return values
bool

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

optInt()

private optInt(array<string, mixed> $opts, string $key) : int|null
Parameters
$opts : array<string, mixed>
$key : string
Return values
int|null

paymentExists()

Checks payment existence through the owner of `cms_system_payments` ({@see PaymentManager::exists()}), not a raw SELECT from the e-shop. Deliberately not `find()`: it hydrates the entity (needless) and `GetAllTrait::clearDoctrine()` in the chain could detach the already loaded order graph.

private paymentExists(int $paymentId) : bool
Parameters
$paymentId : int
Return values
bool

prepareEmails()

Renders the transition e-mails before the final transaction, which then only inserts the ready rows ({@see EmailQueueManager::enqueue()}): the transactional outbox stays atomic with the status write while Latte and I/O hold no locks. An empty template, a missing address or a failing render returns null, so nothing is queued.

private prepareEmails(TOrderGraph $order, TOrderStatusGraph $target, array<string, mixed> $opts, string|null $invoicePdfPath, Voucher|null $generatedVoucher, array{voucherLevelId: int, durationDays: int, minPrice: string, maxPrice: string, percents: int, fixedAmount: ?string}|null $generatedVoucherLevel[, string|null $invoiceVariableSymbol = null ]) : array<int, PreparedOrderEmail>

Call only after the intents were applied to the entity (the dispatch notice reads the target status name from order.status).

Parameters
$order : TOrderGraph
$target : TOrderStatusGraph
$opts : array<string, mixed>
$invoicePdfPath : string|null
$generatedVoucher : Voucher|null
$generatedVoucherLevel : array{voucherLevelId: int, durationDays: int, minPrice: string, maxPrice: string, percents: int, fixedAmount: ?string}|null
$invoiceVariableSymbol : string|null = null
Return values
array<int, PreparedOrderEmail>

reconcileFromDb()

Syncs the entity with the authoritative state of the decision columns from the DB (under the lock), protecting against a stale entity from the Doctrine identity map. `em->clear()` is never called here because callers keep references to the loaded graph.

private reconcileFromDb(Order $order, int $orderId) : void
Parameters
$order : Order
$orderId : int

rejectAfterStockEffects()

Compensates a rejected transition after step 5/6. The reservation made by a restore was committed by Store outside our transaction, so no rollback returns it. The compensation is needed on every reject path after step 6, not only in the catch of the final transaction, otherwise a rejected transition would leave a canceled order holding stock.

private rejectAfterStockEffects(Result $failure, TOrderGraph $order, bool $renewReserved) : Result
Parameters
$failure : Result
$order : TOrderGraph
$renewReserved : bool
Return values
Result

reRateTaxAtShipping()

Re-rates VAT when dispatching an unpaid order. Unpaid means no paired payment (`order_payments`): cash on delivery and invoices with a due date are paid only after dispatch. Fail-soft: a re-rate error does not fail the dispatch (logged, the original rate stays).

private reRateTaxAtShipping(TOrderGraph $order, int $orderId, array<string, mixed> $memory) : bool
Parameters
$order : TOrderGraph
$orderId : int
$memory : array<string, mixed>

in-memory intents (the dispatch date is already in them)

Return values
bool

result()

private result(bool $noop, array<int, string> $warnings, int|null $invoiceId, int|null $stockDocumentId) : Result
Parameters
$noop : bool
$warnings : array<int, string>
$invoiceId : int|null
$stockDocumentId : int|null
Return values
Result

shipperExists()

private shipperExists(int $shipperId) : bool
Parameters
$shipperId : int
Return values
bool

stringList()

Normalizes warnings from nested service Results (`getData()` is `mixed`) to a list of strings.

private stringList(mixed $value) : array<int, string>
Parameters
$value : mixed
Return values
array<int, string>

transition()

private transition(int $orderId, int $newStatusId, array<string, mixed> $opts) : Result
Parameters
$orderId : int
$newStatusId : int
$opts : array<string, mixed>
Return values
Result

undoRenewReservation()

Releases the reservation made by restoring a canceled order when the transition is rejected.

private undoRenewReservation(Order $order, bool $renewReserved) : void

Store committed it in its own transaction before the final one, so a rollback does not return it, and a canceled order must not keep holding stock.

Parameters
$order : Order
$renewReserved : bool

voucherIds()

Returns ids of vouchers applied to the order (only those linked to a live voucher; a snapshot without an FK has no counter to give back to).

private voucherIds(Order $order) : array<int, int>
Parameters
$order : Order
Return values
array<int, int>

writeOrderColumns()

Writes the intents through {@see OrderManager::updateColumns()} with a dynamic SET list: only the columns the transition really changes (never a full object-graph save, which would endanger order collections through orphanRemoval). `$intents` keys are `Order` entity properties; the mapper translates them to columns (also FKs of ToOne associations such as `status`/`payment`/ `paymentRegion`/`expeditionShipper`, where the value is the id). `updateColumns()` runs no save hooks and uses the same connection, so it stays inside the caller's final transaction.

private writeOrderColumns(int $orderId, array<string, mixed> $intents) : void

IMPORTANT: the repository swallows a DBAL exception in updateColumns() and returns Result::failure. Inside a transaction that would mean a silent partial transition (status not written but e-mails and voucher counters committed), so the failure is turned back into an exception: the catch of the final transaction rolls back everything and returns transactionFailed.

Parameters
$orderId : int
$intents : array<string, mixed>
On this page

Search results