CoolCMS API

OrderManager extends Manager
in package
uses BlacklistGuardTrait, OrderBuildTrait, OrderCronTrait

Order manager: owns the order model and its calculation.

State transitions (invoice, e-mails, vouchers, points) stay behind the single entry point OrderTransitionManager and do not belong here. Stock reservation is delegated to OrderStockManager.

beforeSave() (inside the save transaction):

  • Insert: fills insertDatetime, generates orderNumber and the variable symbol via OrderNumberGenerator, and calls VoucherManager::incrementUsage() for every voucher with a live FK. An exhausted voucher rejects the whole save atomically and the rejection calls em->clear().
  • Update: rejects an order without items when the DB has items, because the managed copy would delete all items, units and fees through orphan removal and the totals would be recalculated to 0 (use updateColumn() for partial updates). Stores a snapshot of order_item_units in $meta['_unitsBefore'] and diffs the voucher FKs against the DB state, incrementing added and decrementing removed vouchers. The voucher diff is skipped when the DB has is_cancelled=1 (read by raw SQL, the entity may be stale): the counters of a canceled order belong to OrderTransitionManager, otherwise a later restore would increment twice.
  • Always: authoritative recalculation through OrderCalculator::recalculate(); stored totals are not trusted.

completedSave() (post-commit, best-effort): insert calls OrderStockManager::reserve(), an update with changed units calls rereserve(), unchanged units do not touch the stock. $meta['allowPartial'] overrides the default, $meta['skipReserve']=true skips the stock and the order document refresh. A stock failure does not fail the save (the order is valid unreserved, see stock_state); it only logs a warning and fills data['stockWarnings']. Every successful update also refreshes an existing order document.

beforeDelete() snapshots the voucher FKs, is_cancelled and stock_state by raw SQL before the cascade delete of cms_mod_eshop_order_voucher_rel (the rows are gone by completedDelete()). completedDelete() calls OrderStockManager::releaseDeletedOrder() per order and decrements the vouchers from the snapshot, except for canceled orders (the cancellation already did). Both hooks take the order id from the DeleteTrait snapshot, never from the removed entity.

Tags
phpstan-import-type

TOrderGraph from OrderCalculator

extends

Table of Contents

Constants

DEFAULT_UNPAID_CANCEL_AFTER_DAYS  : int = 30
Fallback of {@see UNPAID_CANCEL_SETTING_KEY} when the row is missing or not a positive number (user decision 18. 10.: 30 days).
UNPAID_CANCEL_SETTING_KEY  : string = 'order.unpaid.cancelAfterDays'
Setting (module {@see UNPAID_CANCEL_SETTING_MODULE}, e-shop) with the days after creation an order may stay awaiting payment before the cron cancels it. Edited in Eshop › Settings › Orders.
DELETED_IDS_META  : string = '__deletedIds'
Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.
BUILD_STOCK_SOURCE_MODULE  : int = 4
`cms_system_modules` id of the eshop, the reservation source in the Store journal.
BUILD_STOCK_SOURCE_TYPE  : string = 'order'
EXPIRE_DEFAULT_BATCH  : int = 50
EXPIRE_QUEUE  : string = 'eshop.order.expire'
EXPIRED_STATUS_KEY  : string = 'EXPIRED'
INVOICE_DEFAULT_BATCH  : int = 20
INVOICE_QUEUE  : string = 'eshop.order.invoice'
ORDERS_TABLE  : string = 'cms_mod_eshop_orders'
STATUSES_TABLE  : string = 'cms_mod_eshop_order_statuses'
UNPAID_CANCEL_DEFAULT_BATCH  : int = 50
UNPAID_CANCEL_QUEUE  : string = 'eshop.order.cancel-unpaid'
UNPAID_CANCEL_SETTING_MODULE  : int = 4

Properties

$service  : Service<string|int, TEntity>
Manager service; subclasses narrow the type (`@property` in the Api/Admin/Front manager).
$appContext  : AppContext
$assetFormatRegistry  : AssetFormatRegistry|null
Format processor registry for file uploads, used by {@see \App\Core\Traits\Api\Models\Managers\AssetProcessingTrait}.
$blacklistManager  : BlacklistManager|null
$cacheTagInvalidator  : CacheTagInvalidator|null
Post-commit invalidator of the wire-payload query cache (tag bump per entity class).
$internalCallContext  : InternalCallContext|null
Tells whether the call is an internal bridge call; the single source for reading uploaded items with `path` ({@see readUploadedItem()}).
$languageContext  : LanguageContext|null
$nonInvalidatingColumns  : array<string|int, string>
Columns written by `OrderStockManager` through {@see updateColumns()}.
$paramsConfig  : Config|null
Neon parameters (`%params%`: server paths and directories), set by the decorator (`decorators.neon`) like `setAppContext()`.
$presenterContext  : PresenterContext
$settingContext  : SettingContext
$totalCount  : int|null
$useTransactionForDelete  : bool
$useTransactionForSave  : bool
$calculator  : OrderCalculator
$cronFailureManager  : CronFailureManager
$currencyManager  : CurrencyManager
$customerManager  : OrderCustomerManager
$em  : EntityManagerInterface
$invoiceGenerator  : OrderInvoiceGenerator
$languageManager  : LanguageManager
$localizationManager  : LocalizationManager
$numberGenerator  : OrderNumberGenerator
$orderPayloadHelper  : OrderPayloadHelper
$orderStatusManager  : OrderStatusManager
$orderTaxManager  : OrderTaxManager
$orderTransitionManager  : OrderTransitionManager
$paymentRegionManager  : PaymentRegionManager
$paymentRegionService  : OrderPaymentRegionService
$pickupPointApplier  : OrderPickupPointApplier
$rejectedUploads  : array<int, array{name: string, reason: string}>
New uploaded items that the Api did not save during the `save()` that is running right now (type, dimensions, unreadable, …). Filled by {@see recordRejectedUpload()} and returned in `saveInfo.rejectedUploads`.
$snapshotBuilder  : OrderSnapshotBuilder
$stockItemManager  : StockItemManager
$stockManager  : OrderStockManager
$stockReservationManager  : StockReservationManager
$unitOfWorkReset  : UnitOfWorkReset
$userGroupManager  : UserGroupManager
$userTypeManager  : UserTypeManager
$voucherManager  : VoucherManager

Methods

__construct()  : mixed
cancelUnpaid()  : array{processed: int, cancelled: int, skipped: int, errors: list}
Cancels orders that stayed awaiting payment (card/gateway or bank transfer) for {@see unpaidCancelAfterDays()} days since creation (cron `/cron/eshop/order/cancel-unpaid`).
delete()  : Result
Deletes the entity or entities by id. Optional hooks (opt-in via `method_exists`, like {@see SaveTrait}) allow cleanup of derived or denormalized state during the delete:
deleteAll()  : Result
expireUnpaid()  : array{processed: int, expired: int, skipped: int, errors: list}
Expires unpaid orders periodically (cron `/cron/eshop/order/expire`).
find()  : T|null
findAll()  : T> : list>)
findBy()  : T|null
findRelatedIds()  : array<int, int>|null
Returns the ids of target entities that already belong to the record `$parentId` through the relation collection `$property` (for example `('assetRels', 'asset')`, `('fileRels', 'file')`). Reads straight from the database, not through the Api read path (no `UoW::clear()` in the middle of save hooks).
getAppContext()  : AppContext
getBuildData()  : array{success: bool, message?: string, errorCode?: string, id?: int|null, order?: array, warnings?: list}
Builds, recalculates and optionally saves an order from the trusted rows of the admin edit form.
getDefaultSkipFields()  : array<string|int, string>
Returns keys that the generic `service->save()` must not copy onto the managed entity.
getEntityClass()  : TEntity>
Returns the root Doctrine entity class managed by this manager.
getExternalMetaAllowlist()  : array<string|int, string>
Returns the meta keys allowed through the external HTTP path ({@see \App\Core\Traits\Api\Presenters\SaveTrait::actionSave()}).
getExternalOwnerData()  : array<string, mixed>
Returns `data` keys that fill the owner from the server when an external save creates a record.
getExternalRemoteIpData()  : array<string, mixed>
Returns `data` keys that fill the caller's IP from the server when an external save creates a record; sibling of {@see getExternalOwnerData()}.
getExternalUpdateColumnsAllowlist()  : array<string|int, string>
Returns the columns the external HTTP path may change through {@see \App\Core\Traits\Api\Presenters\SaveTrait::actionUpdateColumns()}.
getGraphDataKeys()  : array<string|int, string>
Returns the keys an external client may send nested in `data`.
getItemPrefillData()  : array{success: bool, message?: string, errorCode?: string, code?: ?string, name?: string, unit?: ?string, vat?: string, price?: string}
Returns prefill data for the order item picker (`api/eshop/order/item-prefill`).
getLanguageContext()  : LanguageContext|null
getPresenterContext()  : PresenterContext
getServiceClass()  : class-string
Returns the fully qualified service class name, the identity for the per-service rollout allowlist of the wire-payload query cache ({@see \App\Core\Cache\Config::isServiceEnabled()}).
getSettingContext()  : SettingContext
getSettings()  : array<int, array<string, mixed>>
getTotalCount()  : int|null
initializeAllContexts()  : void
isOwnedBy()  : bool
Returns whether order `$orderId` belongs to user `$userId`; the single definition of order ownership.
ownershipMatches()  : bool
Pure, stateless ownership match, testable without a DB.
registerEvent()  : Result
runInvoiceCatchup()  : array{processed: int, generated: int, permanent: int, skipped: int, errors: list}
Issues invoices for orders catch-up style (cron `/cron/eshop/order/invoice`).
save()  : Result
setAppContext()  : $this
setAssetFormatRegistry()  : static
setCacheTagInvalidator()  : static
setInternalCallContext()  : static
setLanguageContext()  : $this
setParamsConfig()  : static
setPresenterContext()  : $this
setSettingContext()  : $this
unpaidCancelAfterDays()  : int
Days an order may stay awaiting payment before {@see cancelUnpaid()} cancels it: the setting {@see UNPAID_CANCEL_SETTING_KEY}, else {@see DEFAULT_UNPAID_CANCEL_AFTER_DAYS}.
updateAllRelations()  : Result
updateColumn()  : Result
updateColumns()  : Result
updateRelations()  : Result
updateStatusForPayment()  : Result
Fills the payment region of orders paired with a payment; called by the gateway callback {@see EshopPaymentHook::handlePayment()} (`callback_key = ESHOP_ORDER`).
beforeDelete()  : Result|null
Snapshots what `completedDelete()` needs per order while the rows exist: the voucher FKs (before the `ON DELETE CASCADE` of `cms_mod_eshop_order_voucher_rel`), `is_cancelled` and `stock_state`. It runs post-commit, where a lazy load would find nothing and the removed entity has no id.
beforeSave()  : Result|null
beginRejectedUploads()  : array<int, array{name: string, reason: string}>
Starts a save ({@see save()}): the list of rejected items belongs to this save only; the outer one (a nested save of the same manager) is returned to the caller and restored after the save through {@see finishRejectedUploads()}.
bumpCacheTags()  : void
Bumps cache tags for the entity after commit: its class, the owned-cascade classes from metadata and the optional `additionalCacheTags()` of the manager.
bumpCacheTagsExplicit()  : void
Bumps the given classes explicitly (raw-DBAL paths with a cross-entity effect).
bumpCacheTagsForClass()  : void
Bumps cache tags by class name when there is no instance (bulk delete, `updateColumns()`, raw DBAL, tree operations).
classCacheTags()  : array<string|int, string>
Returns extra tags for a bump that has no entity, typically tree operations ({@see \App\Core\Traits\Api\Models\Managers\TreeTrait::treeTransaction()}) that rewrite `lft`/`rgt` on many rows.
completedDelete()  : void
Releases stock and decrements vouchers per deleted order, only from the values captured before the delete: the id from the `DeleteTrait` snapshot (Doctrine nulls the id of the removed instance) and the `beforeDelete()` state. The Store `SourceRef` survives the order delete (Store has no FK to the e-shop), so the release by id still works.
completedSave()  : void
Post-commit stock handling (best-effort, see the class docblock). `$meta['skipReserve']` skips reserve and rereserve completely; `$meta['allowPartial']` (bool) overrides the default resolution in {@see OrderStockManager}.
finishRejectedUploads()  : void
Ends a save: whatever {@see recordRejectedUpload()} wrote during it is added to the data of a successful result as `saveInfo.rejectedUploads` (the Api presenter passes it on to the caller, the admin builds a warning from it). A rejected item does not fail the save; the accepted files stay.
getDeletedId()  : int|null
Returns the id `$entity` had before the delete, from the snapshot in `$meta` that {@see delete()} hands to every hook. Null for an entity the snapshot does not know (not loaded by this delete, or without a simple id).
getService()  : Service<string|int, TEntity>
Returns the service backing the manager traits.
readUploadedItem()  : string|null
Reads the content of an uploaded item (`{name, data}` base64 or `{name, path}` from the dropzone) through the single reader {@see UploadedItem}.
recordRejectedUpload()  : void
Records a new uploaded item that the save skipped ({@see UploadRejection} `REASON_*`).
applyStockResult()  : void
A stock operation failure must never fail an already committed save or delete; it only logs a warning and appends it to `Result::data['stockWarnings']` (the order is valid unreserved, `stock_state` says so).
blacklistRefusal()  : Result|null
buildFailureArray()  : array{success: false, errorCode: string, message: string}
buildNumOrZero()  : numeric-string
Converts a DBAL/bcmath scalar (mixed) to a numeric-string, guarded (like `OrderCalculator::norm()`).
buildRef()  : T
Returns a managed reference without a DB query (like {@see \App\UI\Api\Eshop\Models\Services\OrderSnapshotBuilder::ref()}).
bumpDeleteCacheTags()  : void
Post-commit bump after a delete. With entities it goes through {@see \App\Core\Base\BaseManager::bumpCacheTags()}, which also collects `additionalCacheTags()` (Comment → CommentThread); without them it falls back to bumping the manager class itself, the original behavior.
cancelDueUnpaidOrderIds()  : array<int, int>
Returns orders awaiting payment created before the cutoff, oldest first (read only; the fresh load is done by `apply()`). Parked orders arrive as an id list.
captureDeletedIds()  : WeakMap<object, int>
Keys the ids by the loaded entity objects, not by the input ids: an input id that loaded nothing must not shift the pairing. A weak map, so the snapshot never keeps an entity alive.
checkStrictStockGate()  : array{success: false, errorCode: string, message: string}|null
Strict stock gate: called on `persist=true` before `OrderManager::save()`, only when `!$allowPartial`.
columnsInvalidateCache()  : bool
currentUnitsSnapshot()  : array<int, int>
deleteBatch()  : Result
Bulk delete of a list of ids as one transaction. `Service::deleteAll()` flushes per entity; in autocommit the rows deleted before a database failure would stay deleted while the caller got either an escaped `EntityManagerClosed` or an "OK" with an error text. Here the service stops at the technical failure, this wrapper rolls the whole batch back and returns the failure Result with `deletedCount = 0` and `rolledBack = true`: nothing of the batch is durable and nothing is attempted over the closed EntityManager. A domain partial success (missing ids) keeps the existing contract and is committed.
dueInvoiceOrders()  : array<int, TOrderGraph>
Returns orders of the given status that have no handled invoice for it yet (`invoiced_status` is null or differs from the status), like {@see \App\UI\Api\Invoicer\Models\Managers\InvoiceManager} `duePayments()`.
expireDueOrderIds()  : array<int, int>
Returns unpaid orders past `reservation_expires_at` whose current status allows cancelling (`allow_cancelling=1`), via raw SQL (read only; the fresh load is done by `apply()`).
fetchUnitsSnapshot()  : array<int, int>
fetchVoucherIdsBefore()  : array<int, int>
Returns ids of live vouchers applied to the order according to the DB, not the in-memory entity. The update branch of `beforeSave()` diffs against it, and `beforeDelete()` needs it before the cascade delete of `cms_mod_eshop_order_voucher_rel`.
findExpiredStatus()  : TOrderStatusGraph|null
isAssetPathAllowed()  : bool
Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.
loadEntitiesForCacheTags()  : array<int, object>
Loads the entities being deleted only to find out their `additionalCacheTags()`. Fail-soft: what cannot be loaded is simply missing and the bump falls back to the class; it must never stop the delete.
loadPreviousCustomerLink()  : array{0: ?int, 1: ?string, 2: ?int}
Reads the previous customer link state before the Doctrine entity is loaded.
logInvoiceWarning()  : void
Fail-soft log plus `Result::data['invoiceWarnings']` (same as `stockWarnings`).
orderIdsForPayment()  : array<int, int>
Returns ids of orders paired with the payment. The authoritative source is the join table `cms_mod_eshop_order_payments`, not the denormalized `Order.payment` (only the last paired payment); one payment may theoretically cover several orders.
orderVoucherIds()  : array<int, int>
Returns unique ids of live vouchers (the FK really exists) in the in-memory collection `$entity->getVouchers()`. A snapshot without a live voucher is skipped because there is no counter to adjust.
ownActiveReservations()  : array<int, string>
Returns the sum of this order's active (`status=1`) reservations per stock item.
regenerateOrderDocument()  : void
Refreshes the order document, see {@see OrderInvoiceGenerator::regenerateOrderDocument()}.
resolveAllowPartial()  : bool
Resolves `allowPartial`: the payload override (bool, only when explicitly `true`/`false`), otherwise `4/order.partial.adminAllowed`.
resolveBuyerType()  : int
Computes `buyerType` (0 = natural person, 1 = company) server-side; it is not a form field.
resolveCustomerLink()  : array{0: ?int, 1: ?int}
Resolves the card-file customer and contact for the order being built.
resolveDefaultPaymentRegionId()  : int
Resolves the VAT payment region for a new snapshot: setting `4/invoicer.paymentRegionId`, then `7/invoice.default.paymentRegionId` (no prefix), then the localization default ({@see PaymentRegionManager::getDefaultPaymentRegionId()}).
rollbackBatch()  : void
saveWithHooks()  : Result
Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.
statusesRequiringInvoice()  : array<int, OrderStatus>
Returns active statuses with `createInvoice=1` (the batch is a budget across them, like {@see \App\UI\Api\Invoicer\Models\Managers\InvoiceManager} `rulesByStatus()`).
touchOrderCollectionsForInvoice()  : void
Forces hydration of all order collections that {@see OrderInvoiceGenerator::appendLines()} reads, before any `find()`/`findAll()` inside the generator.
usesAssetPathTrait()  : bool

Constants

DEFAULT_UNPAID_CANCEL_AFTER_DAYS

Fallback of {@see UNPAID_CANCEL_SETTING_KEY} when the row is missing or not a positive number (user decision 18. 10.: 30 days).

public int DEFAULT_UNPAID_CANCEL_AFTER_DAYS = 30

UNPAID_CANCEL_SETTING_KEY

Setting (module {@see UNPAID_CANCEL_SETTING_MODULE}, e-shop) with the days after creation an order may stay awaiting payment before the cron cancels it. Edited in Eshop › Settings › Orders.

public string UNPAID_CANCEL_SETTING_KEY = 'order.unpaid.cancelAfterDays'

DELETED_IDS_META

Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.

protected string DELETED_IDS_META = '__deletedIds'

Read it only through getDeletedId().

BUILD_STOCK_SOURCE_MODULE

`cms_system_modules` id of the eshop, the reservation source in the Store journal.

private int BUILD_STOCK_SOURCE_MODULE = 4

BUILD_STOCK_SOURCE_TYPE

private string BUILD_STOCK_SOURCE_TYPE = 'order'

EXPIRED_STATUS_KEY

private string EXPIRED_STATUS_KEY = 'EXPIRED'

INVOICE_QUEUE

private string INVOICE_QUEUE = 'eshop.order.invoice'

ORDERS_TABLE

private string ORDERS_TABLE = 'cms_mod_eshop_orders'

STATUSES_TABLE

private string STATUSES_TABLE = 'cms_mod_eshop_order_statuses'

UNPAID_CANCEL_DEFAULT_BATCH

private int UNPAID_CANCEL_DEFAULT_BATCH = 50

UNPAID_CANCEL_QUEUE

private string UNPAID_CANCEL_QUEUE = 'eshop.order.cancel-unpaid'

UNPAID_CANCEL_SETTING_MODULE

private int UNPAID_CANCEL_SETTING_MODULE = 4

Properties

$service

Manager service; subclasses narrow the type (`@property` in the Api/Admin/Front manager).

public Service<string|int, TEntity> $service

$assetFormatRegistry

Format processor registry for file uploads, used by {@see \App\Core\Traits\Api\Models\Managers\AssetProcessingTrait}.

protected AssetFormatRegistry|null $assetFormatRegistry = null

Set by a decorator (decorators.neon) like setParamsConfig(): more than ten managers process assets and each has its own constructor, so constructor injection would touch all of them.

$cacheTagInvalidator

Post-commit invalidator of the wire-payload query cache (tag bump per entity class).

protected CacheTagInvalidator|null $cacheTagInvalidator = null

Set by the decorator (decorators.neon) like setParamsConfig(). Nullable and fail-open: without wiring (or without Redis) a bump is a no-op.

$internalCallContext

Tells whether the call is an internal bridge call; the single source for reading uploaded items with `path` ({@see readUploadedItem()}).

protected InternalCallContext|null $internalCallContext = null

Only ApiBridge::internalCall() sets it, never the payload and not the RBAC guard, so the emergency apiRbac.internalMode: off cannot disable it. Set by a decorator like setAssetFormatRegistry(). Without it (direct manager call from cron or a test) the call counts as external.

$nonInvalidatingColumns

Columns written by `OrderStockManager` through {@see updateColumns()}.

protected array<string|int, string> $nonInvalidatingColumns = ['views', 'viewsList', 'viewsDetail', 'stockState', 'reservationExpiresAt', 'stockDocumentId']

They are stock state, not order content: bumping the cache tags of the whole Order class on every reservation or release would break the order list query cache several times per checkout, and updateColumns() runs inside runInSourceTransaction(), where invalidating from an uncommitted transaction is what the cache revision avoids.

IMPORTANT: list the columns as entity properties; the mapper translates them to stock_state, reservation_expires_at and stock_document_id.

$paramsConfig

Neon parameters (`%params%`: server paths and directories), set by the decorator (`decorators.neon`) like `setAppContext()`.

protected Config|null $paramsConfig = null

Write logic (asset directories) reads paths from here, not from getSettings() or the setting context, which may be filled by the client without server paths. The property cannot be named $config: it collides with the promoted $config in some managers.

$useTransactionForDelete

protected bool $useTransactionForDelete = true

$useTransactionForSave

protected bool $useTransactionForSave = true

$rejectedUploads

New uploaded items that the Api did not save during the `save()` that is running right now (type, dimensions, unreadable, …). Filled by {@see recordRejectedUpload()} and returned in `saveInfo.rejectedUploads`.

private array<int, array{name: string, reason: string}> $rejectedUploads = []

Methods

__construct()

public __construct(OrderService $service, OrderNumberGenerator $numberGenerator, OrderCalculator $calculator, EntityManagerInterface $em, UnitOfWorkReset $unitOfWorkReset, OrderStockManager $stockManager, VoucherManager $voucherManager, OrderPaymentRegionService $paymentRegionService, OrderInvoiceGenerator $invoiceGenerator, OrderStatusManager $orderStatusManager, OrderTransitionManager $orderTransitionManager, OrderSnapshotBuilder $snapshotBuilder, OrderPayloadHelper $orderPayloadHelper, OrderCustomerManager $customerManager, CurrencyManager $currencyManager, LanguageManager $languageManager, LocalizationManager $localizationManager, UserTypeManager $userTypeManager, StockItemManager $stockItemManager, StockReservationManager $stockReservationManager, UserGroupManager $userGroupManager, PaymentRegionManager $paymentRegionManager, OrderTaxManager $orderTaxManager, OrderPickupPointApplier $pickupPointApplier, CronFailureManager $cronFailureManager[, BlacklistManager|null $blacklistManager = null ]) : mixed
Parameters
$service : OrderService
$numberGenerator : OrderNumberGenerator
$calculator : OrderCalculator
$em : EntityManagerInterface
$unitOfWorkReset : UnitOfWorkReset
$stockManager : OrderStockManager
$voucherManager : VoucherManager
$paymentRegionService : OrderPaymentRegionService
$invoiceGenerator : OrderInvoiceGenerator
$orderStatusManager : OrderStatusManager
$orderTransitionManager : OrderTransitionManager
$snapshotBuilder : OrderSnapshotBuilder
$orderPayloadHelper : OrderPayloadHelper
$customerManager : OrderCustomerManager
$currencyManager : CurrencyManager
$languageManager : LanguageManager
$localizationManager : LocalizationManager
$userTypeManager : UserTypeManager
$stockItemManager : StockItemManager
$stockReservationManager : StockReservationManager
$userGroupManager : UserGroupManager
$paymentRegionManager : PaymentRegionManager
$orderTaxManager : OrderTaxManager
$pickupPointApplier : OrderPickupPointApplier
$cronFailureManager : CronFailureManager
$blacklistManager : BlacklistManager|null = null

cancelUnpaid()

Cancels orders that stayed awaiting payment (card/gateway or bank transfer) for {@see unpaidCancelAfterDays()} days since creation (cron `/cron/eshop/order/cancel-unpaid`).

public cancelUnpaid([int|null $limit = null ]) : array{processed: int, cancelled: int, skipped: int, errors: list}

Like expireUnpaid() it has no cancellation logic of its own: it selects due ids with raw SQL over the module's tables (OrderTransitionManager::UNPAID_CANCEL_DUE_PREDICATE) and calls only OrderTransitionManager::apply() to the EXPIRED status with the opt cancelUnpaidCreatedBefore, which re-checks under the lock, refuses an order whose payment already brought money (paymentReceived → back-off and an admin notification once attempts are exhausted), releases the reservation, gives back vouchers and queues the status e-mail of EXPIRED ("not paid within the period, cancelled"). A late gateway payment then leaves the order cancelled and alerts the admin (EshopPaymentHook), and the gateway refuses to start a new payment for it (isPaymentPayable()).

Idempotent: is_cancelled=1 removes the order from the next selection and apply() is a no-op on the same status, so a second run sends no second e-mail.

Parameters
$limit : int|null = null
Return values
array{processed: int, cancelled: int, skipped: int, errors: list}

delete()

Deletes the entity or entities by id. Optional hooks (opt-in via `method_exists`, like {@see SaveTrait}) allow cleanup of derived or denormalized state during the delete:

public delete(int|array<string|int, mixed> $id[, array<string, mixed> $meta = [] ]) : Result
  • beforeDelete(array $entities, array $meta): ?Result runs before the delete (validation or capturing context while the rows exist). A failed Result means rollback and return. Its data flows into afterDelete under $meta['__beforeDelete'].
  • afterDelete(array $entities, Result $result, array $meta): ?Result runs after the delete, still in the transaction. It is only for recomputing derived state from the remaining rows (toped_until, member_until, county), idempotent and reversible. A failure rolls back the delete itself (atomicity). It is also the place for the mandatory delete of dependents through a foreign manager (comment, rating and message threads): an owner still managed in the unit of work references them, so they must go after its row, never before it in beforeDelete (collect their ids there). Additive or irreversible effects (credits, e-mails, invoices) do not belong here, only into completedDelete.
  • completedDelete(array $entities, Result $result, array $meta): void runs best effort after the commit, outside the transaction; an exception is only logged (the delete stays).
  • completedAssetCleanup(array $entities, Result $result, array $meta): void runs best effort after the commit and releases the Asset rows and files the deleted entity owned. The body is in AssetProcessingTrait; opt-in is ownedAssetIds(object $entity): array on the manager, without it the hook does not run.

IMPORTANT: the cleanup has its own name instead of completedDelete() because many Api managers define completedDelete() themselves and a class method overrides a trait method without a warning, so the cleanup would silently be missing in them.

IMPORTANT: a soft delete (deleted = 1) goes through save(), not here; assets must not be released there because the record can be restored.

$entities are the entities loaded before the delete (after the DELETE the row is gone, but the PHP object keeps the data, such as FKs for the recomputation).

IMPORTANT: the id is the exception. Doctrine nulls the id of a removed instance on flush(), so a hook must not rely on getId() once the delete ran (today only the read UoW::clear() in the service find() hands the preloaded entities over detached and keeps their id). Every hook therefore gets the ids captured before beforeDelete under the reserved key DELETED_IDS_META, keyed by the loaded entity object (an input id that loaded nothing has no entry); read it through getDeletedId(). The key is always set here, so a caller-supplied value never survives.

Without hooks it behaves exactly as before (no preloading, no transaction). NOTE: deleteAll() (bulk) and the tree deleteNode take another path and do not trigger these hooks.

Parameters
$id : int|array<string|int, mixed>
$meta : array<string, mixed> = []
Return values
Result

deleteAll()

public deleteAll([array<string|int, mixed> $filters = [] ]) : Result
Parameters
$filters : array<string|int, mixed> = []
Return values
Result

expireUnpaid()

Expires unpaid orders periodically (cron `/cron/eshop/order/expire`).

public expireUnpaid([int|null $limit = null ]) : array{processed: int, expired: int, skipped: int, errors: list}

Has no cancellation logic of its own: it selects due order ids with raw SQL (read only) and calls only OrderTransitionManager::apply() to the EXPIRED status (OrderStatus::$key), which handles the fresh load under lock, gating, reservation release, voucher decrement (cancelOrder=1) and the e-mail. Idempotent: apply() is a no-op on an already-target status and is_cancelled=1 removes the order from the next selection (see expireDueOrderIds()), so two consecutive runs produce no second effect.

The scope is deliberately limited to statuses with allow_cancelling=1 (currently CREATED/APPROVED, resolved by JOIN, not hardcoded ids): checkGating() would reject anything else (e.g. WAITING-ON-PAYMENT with a proforma already issued) with transitionNotAllowed. The underlying stock reservation of WAITING-ON-PAYMENT orders expires independently via the Store cron /cron/store/reservation/expire, so this limit does not endanger stock.

Parameters
$limit : int|null = null
Return values
array{processed: int, expired: int, skipped: int, errors: list}

find()

public find(int $id[, string|array<string|int, mixed> $includes = [] ]) : T|null
Parameters
$id : int
$includes : string|array<string|int, mixed> = []
Return values
T|null

findAll()

public findAll([array<string|int, mixed>|null $includes = [] ][, array<string|int, mixed>|null $filters = [] ][, array<string|int, mixed>|null $orderBys = null ][, int|null $offset = null ][, int|null $limit = null ][, array<string|int, mixed>|null $columns = null ][, bool $computeTotalCount = false ]) : T> : list>)
Parameters
$includes : array<string|int, mixed>|null = []
$filters : array<string|int, mixed>|null = []
$orderBys : array<string|int, mixed>|null = null
$offset : int|null = null
$limit : int|null = null
$columns : array<string|int, mixed>|null = null
$computeTotalCount : bool = false
Return values
T> : list>) —

entities, or raw rows when $columns is set

findBy()

public findBy([array<string|int, mixed>|null $filters = [] ][, string|array<string|int, mixed> $includes = [] ]) : T|null
Parameters
$filters : array<string|int, mixed>|null = []
$includes : string|array<string|int, mixed> = []
Return values
T|null

findRelatedIds()

Returns the ids of target entities that already belong to the record `$parentId` through the relation collection `$property` (for example `('assetRels', 'asset')`, `('fileRels', 'file')`). Reads straight from the database, not through the Api read path (no `UoW::clear()` in the middle of save hooks).

public findRelatedIds(int $parentId, string $property, string $targetAssociation) : array<int, int>|null
Parameters
$parentId : int
$property : string
$targetAssociation : string
Return values
array<int, int>|null —

null when it cannot be determined (collection mapping missing / database error)

getBuildData()

Builds, recalculates and optionally saves an order from the trusted rows of the admin edit form.

public getBuildData(array<string, mixed> $payload) : array{success: bool, message?: string, errorCode?: string, id?: int|null, order?: array, warnings?: list}

The catalog only prefills (see getItemPrefillData() and the prefill inside buildShipperFromRow()/buildPaymentMethodFromRow() when price is omitted); a save never silently reprices from the current catalog, what is in the form is stored. The only automation is the VAT computation from base and rate, driven by the order-level withVat. persist=false never calls flush() (read-only getReference()/raw SELECT), so a preview has no side effects even over an existing (managed) order.

payload:

  • id (?int): edited order (null = new),
  • statusId (?int): only on insert; on update the status never changes here (only OrderTransitionManager does that),
  • customer (array): companyName/regNo/vatId/firstname/lastname/street/city/postcode/country/email/phone/informations, plus optional customerId (manual pick of a card-file customer; wins over e-mail matching, persist=true only) and optional customerContactId (manual pick of a concrete customer address; the server checks ownership, a foreign or missing id silently falls back to the first contact, see OrderCustomerManager::manualSelection()),
  • delivery (array): name/street/city/postcode/country/informations,
  • seller (array): seller snapshot with sellerId (required when persist=true, otherwise sellerRequired), referential sellerContactId/sellerBankAccountId and manually editable name/street/city/postcode/country/regNo/vatId/email/phone/informations/vatInformations/bankName/bankAccount/iban/swift. A key missing from the payload leaves the snapshot on the order unchanged (no reset),
  • userId/currencyId/languageId/userTypeId/paymentRegionId (?int): userId also feeds resolveCustomerLink(); a newly created card-file customer (no contact found by e-mail) is linked to it right away (fill-on-create, never overwrites an existing one),
  • withVat (bool), note (?string) which goes to order.informations. buyerType is not a form input: the Api computes it server-side, see resolveBuyerType(), and a payload buyerType is ignored,
  • lines (list<array{setId:int, quantity:int, price:string(base), vat:string, orderItemId?:int, name?:string, code?:string, unit?:string, discountPercents?:string}>): price is required (missing or invalid gives linePriceRequired, no silent fallback to the catalog); orderItemId is an edited existing item and is carried over (see OrderSnapshotBuilder::buildItemsFromRows()),
  • shipping (array{tariffId:?int, price?:string, vat?:string}): an omitted or null price is computed via ShippingPriceResolver (prefill, incl. free_from); a given one is trusted verbatim,
  • payment (array{paymentMethodId:?int, price?:string, vat?:string}): same logic,
  • allowUnavailablePaymentMethod (bool): the admin always sends true (trusted admin path), the front checkout sends false, the gate in OrderSnapshotBuilder is unchanged. When a method with available=0 is saved only thanks to this override, the response carries it in warnings,
  • voucherCodes (list),
  • gifts (list<array{giftId:int, amount?:int}>),
  • pickupPoint (?array<string, mixed>): the raw carrier widget object as issued (the admin keeps it in the hidden pickupPoint); only the driver normalizes it, by Shipper::$scriptName, never the admin or JS,
  • pickupPointManual (?array{code,name,street,city,zip,country}): manual entry by the admin. Applies only when pickupPoint is missing or the driver does not normalize it (the raw payload wins). A tariff without external_pickup silently ignores both fields; a tariff with external_pickup and no usable point gives pickupPointRequired (persist=true only). A carrier without a driver does not block manual entry: the snapshot gets pickup_carrier = 'manual',
  • allowPartial (?bool): override of 4/order.partial.adminAllowed (persist=true only),
  • persist (bool, default false).

warnings in the response are non-blocking notices (success stays true), a list of codes: paymentMethodUnavailable (the chosen payment method has available=0, saved only thanks to allowUnavailablePaymentMethod). The admin OrderFormTrait translates and shows them.

Parameters
$payload : array<string, mixed>
Return values
array{success: bool, message?: string, errorCode?: string, id?: int|null, order?: array, warnings?: list}

getDefaultSkipFields()

Returns keys that the generic `service->save()` must not copy onto the managed entity.

public getDefaultSkipFields() : array<string|int, string>

The manager's own afterSave()/beforeSave() sync trait handles them through $meta, not through the object graph. Backed by an optional protected per-manager property. The write Api endpoint (SaveTrait::internalActionSave()) also uses this accessor to strip the same keys from $data before hydration, because defaultSkipFields alone does not protect hydration.

Return values
array<string|int, string>

getEntityClass()

Returns the root Doctrine entity class managed by this manager.

public getEntityClass() : TEntity>

The write Api endpoint (SaveTrait) takes it from here instead of the request, so a client cannot choose the instantiated PHP class.

Return values
TEntity>

getExternalMetaAllowlist()

Returns the meta keys allowed through the external HTTP path ({@see \App\Core\Traits\Api\Presenters\SaveTrait::actionSave()}).

public getExternalMetaAllowlist() : array<string|int, string>

The internal path (ApiBridge::internalCall() calling internalActionSave() directly, admin and front) is not limited by this allowlist and reads $meta unchanged. The default is an empty array, so external meta is dropped entirely; actionSave() is reachable through HTTP routing regardless of ApiBridge, hence the strip. Override per manager with the protected property $externalMetaAllowlist, analogous to self::getDefaultSkipFields().

Return values
array<string|int, string>

getExternalOwnerData()

Returns `data` keys that fill the owner from the server when an external save creates a record.

public getExternalOwnerData(int $userId) : array<string, mixed>

The default [] means the domain has no owner or must not be created through external save (Order uses the dedicated checkout action, User registers another way). On INSERT there is nothing to own, so the owner cannot be verified, only determined: the owner field is #[ExternalReadonly] (the strip removes it from the payload) and SaveTrait::applyExternalOwner() then fills it from the JWT, so the client value never reaches hydration.

IMPORTANT: return the key that matches the column mapping, otherwise the owner is silently not written:

  • Watchdog/Comment/Rating/Message: scalar column, ['userId' => $userId];
  • Advert (Bazaar/Comcat): mapped column, ['insertUserId' => $userId];
  • Bazaar Response/AdvertFollower: the scalar userId has an #[ORM\AttributeOverride] with insertable: false, updatable: false in the Api leaf and the column belongs to the association $user, so ['user' => ['id' => $userId]]; Comcat Response has no $user association, so there it is ['userId' => $userId];
  • Complaint: the owner is the association $user, ['user' => ['id' => $userId]].

['id' => X] is a reference that hydrateForPersist() looks up in the DB (OrmEntityHydrator::isReference()). Called only on the external path and only on INSERT; on UPDATE it would make whoever saves a foreign record its owner.

Parameters
$userId : int

identity from the JWT (never from the request)

Return values
array<string, mixed>

getExternalRemoteIpData()

Returns `data` keys that fill the caller's IP from the server when an external save creates a record; sibling of {@see getExternalOwnerData()}.

public getExternalRemoteIpData(string $ip) : array<string, mixed>

The IP field is #[ExternalReadonly], so the external strip removes it and the client cannot forge it. Without filling, an external insert would have no IP and the "anonymous by IP" deduplication and per-IP manager limits would silently stop working. SaveTrait::applyExternalRemoteIp() fills it from IRequest::getRemoteAddress(), never from the request. The default [] means the entity carries no IP; overrides return the entity field name (['insertIp' => $ip]).

Parameters
$ip : string
Return values
array<string, mixed>

getExternalUpdateColumnsAllowlist()

Returns the columns the external HTTP path may change through {@see \App\Core\Traits\Api\Presenters\SaveTrait::actionUpdateColumns()}.

public getExternalUpdateColumnsAllowlist() : array<string|int, string>

updateColumn on a to-many relation is DELETE-all plus INSERT, so without this filter an external POST with values={userRoles: [...]} would bypass the strip/guard of the save path (privilege escalation). The internal path (ApiBridge::internalCall() calling internalActionUpdateColumns() directly) is not limited. The default is an empty array, so external updateColumns is rejected entirely. Override with the protected property $externalUpdateColumnsAllowlist.

Return values
array<string|int, string>

getGraphDataKeys()

Returns the keys an external client may send nested in `data`.

public getGraphDataKeys() : array<string|int, string>

Before hydration the write Api endpoint (SaveTrait::internalActionSave()) moves them from $data to $meta, where the manager's own afterSave()/beforeSave() sync trait reads them (the same mechanism as internal meta). When the client also sends the same key explicitly in meta, meta wins (compatibility with admin, e.g. the categoryRels collision on Advert). Backed by an optional protected per-manager property, like self::getDefaultSkipFields().

IMPORTANT: promoting a key here does not make its content safe to write as received. When the key carries a sensitive field that the server otherwise dictates (typically a payment or approval flag), the manager must overwrite or verify it server-side on the external path in beforeSave()/afterSave() before its sync trait reads it; otherwise the client sets it itself (privilege escalation). The external path is recognized by $meta['__external'] === true, which only SaveTrait::actionSave() sets (never internalActionSave() alone), so internal calls never get the marker. Example: Bazaar Advert.categoryRels is persisted through the object graph (owned collection outside $graphDataKeys); a client could mark paid itself and get a paid advert without payment, so AdvertManager::beforeSave() overwrites it through CategoryRelsPaidSanitizationTrait::sanitizeExternalCategoryRelsPaid() before flush.

Return values
array<string|int, string>

getItemPrefillData()

Returns prefill data for the order item picker (`api/eshop/order/item-prefill`).

public getItemPrefillData(int $setId, int $quantity, int $currencyId, int $languageId[, int|null $paymentRegionId = null ]) : array{success: bool, message?: string, errorCode?: string, code?: ?string, name?: string, unit?: ?string, vat?: string, price?: string}

The single data source for the OrderItemPickerTrait force-submit: the admin cannot compute catalog prices/VAT itself. Saves nothing, only resolves values as of now.

Parameters
$setId : int
$quantity : int
$currencyId : int
$languageId : int
$paymentRegionId : int|null = null
Return values
array{success: bool, message?: string, errorCode?: string, code?: ?string, name?: string, unit?: ?string, vat?: string, price?: string}

getServiceClass()

Returns the fully qualified service class name, the identity for the per-service rollout allowlist of the wire-payload query cache ({@see \App\Core\Cache\Config::isServiceEnabled()}).

public getServiceClass() : class-string

The cache is controlled at the Api presenter boundary through this identity.

Return values
class-string

getSettings()

public getSettings() : array<int, array<string, mixed>>
Return values
array<int, array<string, mixed>>

getTotalCount()

public getTotalCount() : int|null
Return values
int|null

isOwnedBy()

Returns whether order `$orderId` belongs to user `$userId`; the single definition of order ownership.

public isOwnedBy(int $orderId, int $userId) : bool

Used by the RBAC gate of the external Api path (OrderPresenter::verifyOwnership()), by parent ownership of a complaint (ComplaintManager::canUserWrite()) and by the front (OrderCheckoutManager::summary(), OrderCheckoutManager::repeatItems()), which call the pure self::ownershipMatches() on an already loaded row.

IMPORTANT: the owner is read from the DB by $orderId; data.user.id or data.userId from the payload never reaches this method, so a caller cannot claim another identity.

IMPORTANT: a guest order (cms_system_users_user_id IS NULL) belongs to nobody and returns false even for a logged-in user. Its ownership is held by the front session (CartManager::ownsOrder()) and the external Api path must not touch it.

Uses raw DBAL instead of find(): only one column is needed and find() would hydrate the whole order aggregate, inside startup() before any action.

Parameters
$orderId : int
$userId : int
Return values
bool

ownershipMatches()

Pure, stateless ownership match, testable without a DB.

public static ownershipMatches(int|null $recordUserId, int $userId) : bool

A null owner (guest order) or 0 never matches, not even against $userId === 0 (an anonymous caller: getUser()->getId() returns null, not 0).

Parameters
$recordUserId : int|null
$userId : int
Return values
bool

registerEvent()

public registerEvent(array<string|int, int> $ids, class-string $entityClass, string $property) : Result
Parameters
$ids : array<string|int, int>
$entityClass : class-string

entity used to translate the property into a column in the expression

$property : string
Return values
Result

runInvoiceCatchup()

Issues invoices for orders catch-up style (cron `/cron/eshop/order/invoice`).

public runInvoiceCatchup([int|null $limit = null ]) : array{processed: int, generated: int, permanent: int, skipped: int, errors: list}

A watermark reconcile of the same shape as InvoiceManager::processPaidPayments(), but over orders: a batch of orders whose current status has createInvoice=1 and whose invoiced_status differs from that status id (null means never invoiced for this status). Has no invoicing logic of its own: it calls only OrderInvoiceGenerator::generateForStatus(), the same generator OrderTransitionManager uses; the generator writes invoiced_status itself on success and when it finds an existing invoice.

Permanent vs transient failures (errorCode from the generator): the codes in OrderInvoiceGenerator::PERMANENT_ERROR_CODES (for example sourceInvoiceMissing, negativeTotal) are permanent (retrying does not help), so the watermark still advances, otherwise the order would eat the batch forever; the admin resolves it manually (see errors in the stats). Anything else (missing seller configuration, DB error, \Throwable) is transient: the watermark is left alone and the order is retried next run.

IMPORTANT: the order includes must be OrderTransitionManager::ORDER_INCLUDES (the same list, notably gifts), otherwise the object graph is cleared incorrectly.

Parameters
$limit : int|null = null
Return values
array{processed: int, generated: int, permanent: int, skipped: int, errors: list}

save()

public save(TEntity $entity[, array<string, mixed> $meta = [] ][, array<int, string> $skipFields = [] ]) : Result
Parameters
$entity : TEntity
$meta : array<string, mixed> = []
$skipFields : array<int, string> = []
Return values
Result

setParamsConfig()

public setParamsConfig(Config $paramsConfig) : static
Parameters
$paramsConfig : Config
Return values
static

unpaidCancelAfterDays()

Days an order may stay awaiting payment before {@see cancelUnpaid()} cancels it: the setting {@see UNPAID_CANCEL_SETTING_KEY}, else {@see DEFAULT_UNPAID_CANCEL_AFTER_DAYS}.

public unpaidCancelAfterDays() : int

IMPORTANT: zero or a negative value never passes through (the admin form allows 1–3650). The status awaiting payment cannot be cancelled by hand (allow_cancelling=0), so "never cancel" would leave such orders hanging forever; 0 would cancel every order awaiting payment on the next run.

Return values
int

updateAllRelations()

public updateAllRelations(int|array<string|int, mixed> $entityId, string $property, array<string, mixed> $values) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$property : string
$values : array<string, mixed>

relation property name => value set on every row

Return values
Result

updateColumn()

public updateColumn(int|array<string|int, mixed> $entityId, string $property, mixed $value) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$property : string
$value : mixed
Return values
Result

updateColumns()

public updateColumns(int|array<string|int, mixed> $entityId, array<string, mixed> $values) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$values : array<string, mixed>

property name => new value

Return values
Result

updateRelations()

public updateRelations(int|array<string|int, mixed> $entityId, string $property, array<string|int, object> $relationEntities) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$property : string
$relationEntities : array<string|int, object>

relation entities with updated values

Return values
Result

updateStatusForPayment()

Fills the payment region of orders paired with a payment; called by the gateway callback {@see EshopPaymentHook::handlePayment()} (`callback_key = ESHOP_ORDER`).

public updateStatusForPayment(Payment $payment) : Result

Acts only on a completed payment and is otherwise a no-op success, so a repeated call is harmless. It is a no-op until an order is paired with the payment (a row in cms_mod_eshop_order_payments, created by the front checkout or manually through OrderTransitionManager).

Fill-if-empty: fills the payment region of every paired order that has none, through updateColumn() on the FK (no object-graph save(), so no orphan removal risk). An admin's manual value is never overwritten; OrderPaymentRegionService decides.

Parameters
$payment : Payment
Return values
Result

beforeDelete()

Snapshots what `completedDelete()` needs per order while the rows exist: the voucher FKs (before the `ON DELETE CASCADE` of `cms_mod_eshop_order_voucher_rel`), `is_cancelled` and `stock_state`. It runs post-commit, where a lazy load would find nothing and the removed entity has no id.

protected beforeDelete(array<int, TOrderGraph> $entities[, array<string, mixed> &$meta = [] ]) : Result|null

The state is read from the DB by raw SQL, like in beforeSave() and OrderStockManager: the in-memory entity may be stale. Keyed by the id from the DeleteTrait snapshot and stored in $meta['_ordersBeforeDelete'].

Parameters
$entities : array<int, TOrderGraph>
$meta : array<string, mixed> = []
Return values
Result|null

beforeSave()

protected beforeSave(bool $isInsert, TOrderGraph &$entity[, array<string, mixed> &$meta = [] ]) : Result|null
Parameters
$isInsert : bool
$entity : TOrderGraph
$meta : array<string, mixed> = []
Return values
Result|null

beginRejectedUploads()

Starts a save ({@see save()}): the list of rejected items belongs to this save only; the outer one (a nested save of the same manager) is returned to the caller and restored after the save through {@see finishRejectedUploads()}.

protected beginRejectedUploads() : array<int, array{name: string, reason: string}>
Return values
array<int, array{name: string, reason: string}> —

the outer save's list

bumpCacheTags()

Bumps cache tags for the entity after commit: its class, the owned-cascade classes from metadata and the optional `additionalCacheTags()` of the manager.

protected bumpCacheTags(object $entity) : void

Fail-open (the invalidator swallows exceptions). Called from the manager Save/Delete/Tree traits after the commit.

Parameters
$entity : object

bumpCacheTagsExplicit()

Bumps the given classes explicitly (raw-DBAL paths with a cross-entity effect).

protected bumpCacheTagsExplicit(array<string|int, string> $classes) : void
Parameters
$classes : array<string|int, string>

bumpCacheTagsForClass()

Bumps cache tags by class name when there is no instance (bulk delete, `updateColumns()`, raw DBAL, tree operations).

protected bumpCacheTagsForClass(class-string $rootClass[, array<string|int, string> $extraTags = [] ]) : void

Bumps the root class and its owned-cascade classes.

Parameters
$rootClass : class-string
$extraTags : array<string|int, string> = []

classCacheTags()

Returns extra tags for a bump that has no entity, typically tree operations ({@see \App\Core\Traits\Api\Models\Managers\TreeTrait::treeTransaction()}) that rewrite `lft`/`rgt` on many rows.

protected classCacheTags() : array<string|int, string>

IMPORTANT: additionalCacheTags(object) receives an entity, so a class-level bump cannot use it; without this hook a delete would lose the cross-entity tag (Comment to CommentThread). A manager whose cross-entity tag does not depend on a particular entity returns it here.

NOTE: unlike additionalCacheTags(), this hook is declared (with an empty default) instead of being an opt-in through method_exists(); method_exists() is always true in a manager that has the method, which phpstan reports as alreadyNarrowedType.

Return values
array<string|int, string>

completedDelete()

Releases stock and decrements vouchers per deleted order, only from the values captured before the delete: the id from the `DeleteTrait` snapshot (Doctrine nulls the id of the removed instance) and the `beforeDelete()` state. The Store `SourceRef` survives the order delete (Store has no FK to the e-shop), so the release by id still works.

protected completedDelete(array<int, Order> $entities, Result $result[, array<string, mixed> $meta = [] ]) : void
Parameters
$entities : array<int, Order>
$result : Result
$meta : array<string, mixed> = []

completedSave()

Post-commit stock handling (best-effort, see the class docblock). `$meta['skipReserve']` skips reserve and rereserve completely; `$meta['allowPartial']` (bool) overrides the default resolution in {@see OrderStockManager}.

protected completedSave(bool $isInsert, Order $entity, Result $result[, array<string, mixed> $meta = [] ]) : void
Parameters
$isInsert : bool
$entity : Order
$result : Result
$meta : array<string, mixed> = []

finishRejectedUploads()

Ends a save: whatever {@see recordRejectedUpload()} wrote during it is added to the data of a successful result as `saveInfo.rejectedUploads` (the Api presenter passes it on to the caller, the admin builds a warning from it). A rejected item does not fail the save; the accepted files stay.

protected finishRejectedUploads(Result|null $result, array<int, array{name: string, reason: string}> $outer) : void
Parameters
$result : Result|null
$outer : array<int, array{name: string, reason: string}>

list from beginRejectedUploads()

getDeletedId()

Returns the id `$entity` had before the delete, from the snapshot in `$meta` that {@see delete()} hands to every hook. Null for an entity the snapshot does not know (not loaded by this delete, or without a simple id).

protected getDeletedId(array<string, mixed> $meta, object $entity) : int|null
Parameters
$meta : array<string, mixed>
$entity : object
Return values
int|null

getService()

Returns the service backing the manager traits.

protected getService() : Service<string|int, TEntity>
Return values
Service<string|int, TEntity>

readUploadedItem()

Reads the content of an uploaded item (`{name, data}` base64 or `{name, path}` from the dropzone) through the single reader {@see UploadedItem}.

protected readUploadedItem(array<string|int, mixed> $item[, bool $strictBase64 = true ][, int|null $maxBytes = null ]) : string|null

Call it right before processing and release the result before the next item, so that files never meet in memory. A path is accepted only during an internal bridge call ($internalCallContext) and only inside the dropzone root. A rejected path is logged to the auth channel and null is returned; callers treat it like unreadable data (skip the item or refuse the save).

Parameters
$item : array<string|int, mixed>
$strictBase64 : bool = true

false for callers that decode loosely (base64_decode($x))

$maxBytes : int|null = null

size limit; for path it is checked before reading

Return values
string|null

recordRejectedUpload()

Records a new uploaded item that the save skipped ({@see UploadRejection} `REASON_*`).

protected recordRejectedUpload(mixed $name, string $reason) : void

Called by the places that drop an item (processOrderedAssets(), advert attachments, product set gallery).

Parameters
$name : mixed
$reason : string

applyStockResult()

A stock operation failure must never fail an already committed save or delete; it only logs a warning and appends it to `Result::data['stockWarnings']` (the order is valid unreserved, `stock_state` says so).

private applyStockResult(int $orderId, Result $stockResult, string $operation, Result $saveResult) : void
Parameters
$orderId : int
$stockResult : Result
$operation : string
$saveResult : Result

blacklistRefusal()

private blacklistRefusal(string|null $email[, string|null $phone = null ][, string|null $ip = null ]) : Result|null
Parameters
$email : string|null
$phone : string|null = null
$ip : string|null = null
Return values
Result|null

buildFailureArray()

private buildFailureArray(Result $result) : array{success: false, errorCode: string, message: string}
Parameters
$result : Result
Return values
array{success: false, errorCode: string, message: string}

buildNumOrZero()

Converts a DBAL/bcmath scalar (mixed) to a numeric-string, guarded (like `OrderCalculator::norm()`).

private buildNumOrZero(mixed $value) : numeric-string
Parameters
$value : mixed
Return values
numeric-string

buildRef()

Returns a managed reference without a DB query (like {@see \App\UI\Api\Eshop\Models\Services\OrderSnapshotBuilder::ref()}).

private buildRef(T> $class, int $id) : T
Parameters
$class : T>
$id : int
Tags
template
Return values
T

bumpDeleteCacheTags()

Post-commit bump after a delete. With entities it goes through {@see \App\Core\Base\BaseManager::bumpCacheTags()}, which also collects `additionalCacheTags()` (Comment → CommentThread); without them it falls back to bumping the manager class itself, the original behavior.

private bumpDeleteCacheTags(array<int, object> $entities) : void
Parameters
$entities : array<int, object>

cancelDueUnpaidOrderIds()

Returns orders awaiting payment created before the cutoff, oldest first (read only; the fresh load is done by `apply()`). Parked orders arrive as an id list.

private cancelDueUnpaidOrderIds(DateTimeImmutable $createdBefore, int $limit[, array<int, int> $parkedIds = [] ]) : array<int, int>
Parameters
$createdBefore : DateTimeImmutable
$limit : int
$parkedIds : array<int, int> = []
Return values
array<int, int>

captureDeletedIds()

Keys the ids by the loaded entity objects, not by the input ids: an input id that loaded nothing must not shift the pairing. A weak map, so the snapshot never keeps an entity alive.

private captureDeletedIds(array<int, object> $entities) : WeakMap<object, int>
Parameters
$entities : array<int, object>
Return values
WeakMap<object, int>

checkStrictStockGate()

Strict stock gate: called on `persist=true` before `OrderManager::save()`, only when `!$allowPartial`.

private checkStrictStockGate(Order $order, bool $allowPartial) : array{success: false, errorCode: string, message: string}|null

Partial mode is unchanged (OrderTransitionManager/restore untouched). An unmapped variant (notInStock > 0 on the builder) behaves exactly like zero stock. It pre-flights the sum of units per stockItemId against StockReader::getAvailabilityBatch(); on update it adds back the order's own active reservations (otherwise a quantity edit would collide with its own old reservation, which rereserve() releases only after this gate). A race window between the pre-flight and completedSave->reserve remains: reserve() is the final arbiter and a stock failure does not fail the save; the gate only moves the decision before the write.

Parameters
$order : Order
$allowPartial : bool
Return values
array{success: false, errorCode: string, message: string}|null —

null = gate projde

columnsInvalidateCache()

private columnsInvalidateCache(array<int, string> $columns) : bool
Parameters
$columns : array<int, string>
Return values
bool

currentUnitsSnapshot()

private currentUnitsSnapshot(Order $entity) : array<int, int>
Parameters
$entity : Order
Return values
array<int, int> —

stockItemId => sum of quantity (in-memory entity after the save)

deleteBatch()

Bulk delete of a list of ids as one transaction. `Service::deleteAll()` flushes per entity; in autocommit the rows deleted before a database failure would stay deleted while the caller got either an escaped `EntityManagerClosed` or an "OK" with an error text. Here the service stops at the technical failure, this wrapper rolls the whole batch back and returns the failure Result with `deletedCount = 0` and `rolledBack = true`: nothing of the batch is durable and nothing is attempted over the closed EntityManager. A domain partial success (missing ids) keeps the existing contract and is committed.

private deleteBatch(array<int, int|string|array<string|int, mixed>> $ids) : Result

Transaction ownership: the wrapper only opens its own level; inside a caller's transaction a rollback here marks the caller's transaction rollback-only, so its final commit rolls the whole scope back (TransactionManager counter contract, the same as the hooked delete() path).

Parameters
$ids : array<int, int|string|array<string|int, mixed>>
Return values
Result

dueInvoiceOrders()

Returns orders of the given status that have no handled invoice for it yet (`invoiced_status` is null or differs from the status), like {@see \App\UI\Api\Invoicer\Models\Managers\InvoiceManager} `duePayments()`.

private dueInvoiceOrders(OrderStatus $status, int $limit[, array<int, int> $parkedIds = [] ]) : array<int, TOrderGraph>

Parked orders are skipped; ordered by id (deterministic).

Parameters
$status : OrderStatus
$limit : int
$parkedIds : array<int, int> = []
Return values
array<int, TOrderGraph>

expireDueOrderIds()

Returns unpaid orders past `reservation_expires_at` whose current status allows cancelling (`allow_cancelling=1`), via raw SQL (read only; the fresh load is done by `apply()`).

private expireDueOrderIds(int $limit[, array<int, int> $parkedIds = [] ]) : array<int, int>

Uses the module's own tables (cms_mod_eshop_*), so the layer boundary guard does not report it. Parked orders arrive as an id list, not a JOIN on a foreign tracking table.

Parameters
$limit : int
$parkedIds : array<int, int> = []
Return values
array<int, int>

fetchUnitsSnapshot()

private fetchUnitsSnapshot(int $orderId) : array<int, int>
Parameters
$orderId : int
Return values
array<int, int> —

stockItemId => sum of quantity (order_item_units in the DB before the save)

fetchVoucherIdsBefore()

Returns ids of live vouchers applied to the order according to the DB, not the in-memory entity. The update branch of `beforeSave()` diffs against it, and `beforeDelete()` needs it before the cascade delete of `cms_mod_eshop_order_voucher_rel`.

private fetchVoucherIdsBefore(int $orderId) : array<int, int>
Parameters
$orderId : int
Return values
array<int, int>

findExpiredStatus()

private findExpiredStatus() : TOrderStatusGraph|null
Return values
TOrderStatusGraph|null

isAssetPathAllowed()

Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.

private isAssetPathAllowed(BaseEntity $entity) : bool
Parameters
$entity : BaseEntity
Return values
bool

loadEntitiesForCacheTags()

Loads the entities being deleted only to find out their `additionalCacheTags()`. Fail-soft: what cannot be loaded is simply missing and the bump falls back to the class; it must never stop the delete.

private loadEntitiesForCacheTags(int|array<string|int, mixed> $id) : array<int, object>
Parameters
$id : int|array<string|int, mixed>
Return values
array<int, object>

Reads the previous customer link state before the Doctrine entity is loaded.

private loadPreviousCustomerLink(int|null $orderId) : array{0: ?int, 1: ?string, 2: ?int}

It must be raw DBAL, not $this->find() (see resolveCustomerLink()). A new order ($orderId === null, or the row was deleted meanwhile) gives [null, null, null].

Parameters
$orderId : int|null
Return values
array{0: ?int, 1: ?string, 2: ?int} —

customerId, customerEmail, customerContactId

logInvoiceWarning()

Fail-soft log plus `Result::data['invoiceWarnings']` (same as `stockWarnings`).

private logInvoiceWarning(TOrderGraph $entity, Result $result, string $suffix) : void
Parameters
$entity : TOrderGraph
$result : Result
$suffix : string

orderIdsForPayment()

Returns ids of orders paired with the payment. The authoritative source is the join table `cms_mod_eshop_order_payments`, not the denormalized `Order.payment` (only the last paired payment); one payment may theoretically cover several orders.

private orderIdsForPayment(int $paymentId) : array<int, int>
Parameters
$paymentId : int
Return values
array<int, int>

orderVoucherIds()

Returns unique ids of live vouchers (the FK really exists) in the in-memory collection `$entity->getVouchers()`. A snapshot without a live voucher is skipped because there is no counter to adjust.

private orderVoucherIds(TOrderGraph $entity) : array<int, int>
Parameters
$entity : TOrderGraph
Return values
array<int, int>

ownActiveReservations()

Returns the sum of this order's active (`status=1`) reservations per stock item.

private ownActiveReservations(int $orderId) : array<int, string>

On update it is added back to availability: the edit would otherwise collide with its own old reservation, which rereserve() releases and recreates later in the same save operation.

Parameters
$orderId : int
Return values
array<int, string> —

stockItemId => numeric-string

regenerateOrderDocument()

Refreshes the order document, see {@see OrderInvoiceGenerator::regenerateOrderDocument()}.

private regenerateOrderDocument(TOrderGraph $entity, Result $result) : void

A no-op when the order has no order document yet. Fail-soft: a failed refresh must never fail an already committed order edit.

Parameters
$entity : TOrderGraph
$result : Result

resolveAllowPartial()

Resolves `allowPartial`: the payload override (bool, only when explicitly `true`/`false`), otherwise `4/order.partial.adminAllowed`.

private resolveAllowPartial(array<string, mixed> $payload) : bool

The same value goes both to the gate (checkStrictStockGate()) and to OrderManager::save()'s $meta['allowPartial']; the gate and the actual reserve()/rereserve() must not diverge.

Parameters
$payload : array<string, mixed>
Return values
bool

resolveBuyerType()

Computes `buyerType` (0 = natural person, 1 = company) server-side; it is not a form field.

private resolveBuyerType(int|null $userTypeId, array<string|int, mixed> $customer) : int
  1. A selected userTypeId decides via UserType.requiresCompanyData() (see UserType).
  2. Without a selected or existing type it falls back to whether the admin filled customer.companyName or customer.regNo.

The payload key buyerType is never read; the server is the only authority.

IMPORTANT: like resolveCustomerLink(), call it before $this->find($id, ...). UserTypeManager::find() goes through GetAllTrait::clearDoctrine() and over a loaded order graph would detach it, so the flush then fails with ORMInvalidArgumentException: another object of class OrderShipper was already present for the same ID (details at the call in getBuildData()). It therefore takes only primitives (?int and the $customer array), no Doctrine entity.

Parameters
$userTypeId : int|null
$customer : array<string|int, mixed>
Return values
int

Resolves the card-file customer and contact for the order being built.

private resolveCustomerLink(int|null $orderId, array<string|int, mixed> $customer[, int|null $userId = null ]) : array{0: ?int, 1: ?int}

A manual pick from the admin picker/select wins over the resolver when the payload carries a real change against the stored state: a new customerId, or the same customer with a different customerContactId (the admin only switched the address). Otherwise OrderCustomerManager::resolve() runs only when the e-mail really changed (always for a new order, where $previousCustomerId === null). With no e-mail change and no manual pick, customerId/customerContactId stay as stored (edit) or null (add without e-mail), which keeps a plain re-save idempotent.

IMPORTANT: call it before $this->find($id, ...) (see the caller getBuildData()), never over a loaded entity. OrderCustomerManager::resolve()/manualSelection() call Api manager findAll()/save() internally, and GetAllTrait::clearDoctrine() clears the whole Doctrine identity map. A loaded order with an eagerly loaded shipper/paymentMethod would be detached and the next flush() would fail with ORMInvalidArgumentException: another object of class OrderShipper was already present for the same ID (this also hit the original e-mail matching on editing an order with a shipper). It therefore takes only primitives ($orderId, the $customer array), no Doctrine entity, so it cannot detach anything.

Parameters
$orderId : int|null
$customer : array<string|int, mixed>
$userId : int|null = null

payload userId (the order's user account), passed to OrderCustomerManager::resolve() for fill-on-create linking of a newly created customer; the manualSelection() branch does not need it because no customer is created there

Return values
array{0: ?int, 1: ?int} —

customerId, customerContactId

resolveDefaultPaymentRegionId()

Resolves the VAT payment region for a new snapshot: setting `4/invoicer.paymentRegionId`, then `7/invoice.default.paymentRegionId` (no prefix), then the localization default ({@see PaymentRegionManager::getDefaultPaymentRegionId()}).

private resolveDefaultPaymentRegionId() : int
Return values
int

saveWithHooks()

Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.

private saveWithHooks(TEntity $entity, array<string, mixed> $meta, array<int, string> $skipFields) : Result
Parameters
$entity : TEntity
$meta : array<string, mixed>
$skipFields : array<int, string>
Return values
Result

statusesRequiringInvoice()

Returns active statuses with `createInvoice=1` (the batch is a budget across them, like {@see \App\UI\Api\Invoicer\Models\Managers\InvoiceManager} `rulesByStatus()`).

private statusesRequiringInvoice() : array<int, OrderStatus>
Return values
array<int, OrderStatus>

touchOrderCollectionsForInvoice()

Forces hydration of all order collections that {@see OrderInvoiceGenerator::appendLines()} reads, before any `find()`/`findAll()` inside the generator.

private touchOrderCollectionsForInvoice(TOrderGraph $entity) : void

IMPORTANT: this path (an edit outside a state transition) has no eager load like ORDER_INCLUDES in OrderTransitionManager. Without it a lazy collection would be hydrated after a foreign clearDoctrine() and its items would carry a detached owner, so the flush would fail with "A new entity was found through the relationship".

Parameters
$entity : TOrderGraph

usesAssetPathTrait()

private static usesAssetPathTrait(string $class) : bool
Parameters
$class : string
Return values
bool
On this page

Search results