CoolCMS API

ProductSetManager extends Manager
in package
uses AssetTrait, AssetProcessingTrait, CommentThreadCreatorTrait, EshopAssetTrait, RatingThreadCreatorTrait

Base class of Api managers whose traits delegate to a typed `Service<TEntity>`.

Tags
extends

Hooks are typed against the shared base ProductSet: the persisted instance is the Api or Admin leaf depending on the caller, and both extend the base.

Table of Contents

Constants

DELETED_IDS_META  : string = '__deletedIds'
Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.
ESHOP_SETTINGS_MODULE  : int = 4
Eshop module index (image directories `assetDir(4, ...)` in managers using this trait).
TABLE  : string = 'cms_mod_eshop_product_sets'

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}.
$cacheTagInvalidator  : CacheTagInvalidator|null
Post-commit invalidator of the wire-payload query cache (tag bump per entity class).
$defaultSkipFields  : array<string|int, string>
Fields stripped from the payload before hydration.
$graphDataKeys  : array<string|int, string>
An external client may send `images` nested in `data`; `internalActionSave()` moves them to `meta`, where {@see processImages()} reads them unchanged.
$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
$lastAssetRejectionCode  : string|null
Why the last {@see saveAssetVariants()} returned `null` ({@see UploadRejection} `REASON_*`); the caller passes it to the report of rejected items. `null` means the last save passed.
$nonInvalidatingColumns  : array<string|int, string>
Columns whose update does not invalidate the cache: view counters (`registerEvent()` bumps the counter on every pageview). Without this every view would flush the query cache of the whole class; counters are therefore stale up to the TTL. Override per manager by redefining the property.
$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
$assetManager  : AssetManager
$commentThreadManager  : CommentThreadManager
$composition  : SetCompositionService
$currencyManager  : CurrencyManager
$em  : EntityManagerInterface
$esDocumentSource  : ProductSetDocumentSource
$esIndexer  : EsEntityIndexer
$esSearcher  : EsSearcher
$languageManager  : LanguageManager
$ownedGalleryAssets  : WeakMap<object, array<int, int>>|null
Gallery image ids of the sets being deleted, captured by {@see beforeDelete()}; after the DELETE (CASCADE on `..._image_relations`) nobody can find them.
$priceRecalculator  : PriceRecalculator
$priceResolver  : PriceResolver
$ratingThreadManager  : RatingThreadManager
$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`.
$stockItemManager  : StockItemManager
$supplyKindManager  : SupplyKindManager
$taxManager  : TaxManager
$unitOfWorkReset  : UnitOfWorkReset
$userGroupManager  : UserGroupManager
$variantProvider  : AssetVariantProvider
$warehouseManager  : WarehouseManager

Methods

__construct()  : mixed
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
Bulk hard delete by filters goes through {@see delete()}: the generic bulk (`DeleteTrait::deleteAll()`) does not run lifecycle hooks and would bypass the history gate.
deletePriceSnapshot()  : Result
Deletes a whole price snapshot (all rows of the given `publishedTime`), only today or the future. Returns a {@see Result} like {@see savePriceSnapshot()} (shares `snapshotOutcome()`).
deleteThreads()  : Result
Deletes the comment and rating threads of vanished sets (the link goes from the set to the thread, so the threads with their comments and ratings would stay orphaned). Called inside the delete transaction, before the set rows are deleted, while the thread ids are still known.
fetchParameterValueCounts()  : array<int, array<string|int, int>>
Counts how many sets of the given collection have each value of each parameter; basis for the filter option counts and for the list of parameters that occur in the listing.
find()  : T|null
findAll()  : ProductSet> : list>)
Lists sets; ordering by price is done in the display currency.
findBy()  : T|null
findHistoryBlockers()  : array<int, string>
Returns the reasons why sets cannot be hard-deleted: an order item, complaint or gift references them (an FK with `SET NULL` would silently detach the history). An empty array means they can be deleted.
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
getAvailabilities()  : array<int, SetAvailability>
Returns the availability of sets for the catalog; the SQL count is constant, independent of the set count.
getAvailability()  : SetAvailability|null
Returns the availability of one set for the catalog; see {@see getAvailabilities()} for batches.
getComposition()  : SetDecomposition
Decomposes one set into its leaf stock items; see {@see getCompositions()} for an independent batch.
getCompositionMany()  : SetDecomposition
Decomposes N sets summed together (one reservation/order); {@see getCompositions()} decomposes N sets independently of each other (catalog).
getCompositions()  : array<int, SetDecomposition>
Decomposes N sets independently of each other with one shared set of SQL queries; consumed by {@see getAvailability()} below and by checkout.
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`.
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
recountPrices()  : int
Self-heals the denormalized sort column `product_sets.price` from the view `current_prices` (cron).
registerEvent()  : Result
reindexSearch()  : void
Indexes sets after a bulk write with `suppressSearchIndex` (product import): the same channel as {@see completedSave()}, in batches. Fail-open (the indexer logs and opens the circuit breaker itself); a disabled source is a no-op.
releaseDeletedSetAssets()  : void
Releases the images of vanished sets after the commit of a hard delete of a variant ({@see ProductVariantManager}). This manager owns the set images, so the release goes through it (a shared image stays).
removeFromSearchIndex()  : void
Deletes set documents from ES after their hard delete outside {@see delete()} (product delete cascade, {@see ProductHardDeleteManager}). Fail-open like all indexing.
resolveDefaultDisplayTreatment()  : array{chargeVat: bool, vatPercents: string, noteKey: ?string, code: string}
Resolves the display tax treatment of the default supply kind (`SupplyKindManager::findFallbackId()`) in the given payment region; basis for {@see \App\UI\Api\Eshop\Presenters\ProductSetPresenter::internalActionPriceDisplay()} (a summary, not per set).
resolveDisplayTaxes()  : array<int, array{supplyKindId: int, chargeVat: bool, vatPercents: string, noteKey: ?string, code: string}>
Resolves the display tax treatment of each set: one batch query for the supply kind (raw DBAL, `cms_system_supply_kinds_supply_kind_id`) and one {@see TaxManager::resolveDisplay()} call for all distinct supply kinds, not per set.
resolveNextPriceTimes()  : array<int, string>
Returns the nearest future `published_time` of the price of each given set, in batch (countdown of the weekly deals section, {@see \App\UI\Api\Eshop\Presenters\ProductSetPresenter::internalActionNextPriceTimes()}).
resolvePrice()  : ProductSetPriceResult|null
Returns the currently valid selling price of one set; see {@see resolvePrices()} for batches.
resolvePrices()  : array<int, ProductSetPriceResult>
Resolves catalog prices for many sets with one SQL query, regardless of the set count.
save()  : Result
savePriceSnapshot()  : Result
Saves or replaces a price snapshot.
setAppContext()  : $this
setAssetFormatRegistry()  : static
setCacheTagInvalidator()  : static
setInternalCallContext()  : static
setLanguageContext()  : $this
setParamsConfig()  : static
setPresenterContext()  : $this
setSettingContext()  : $this
snapshotWire()  : array{success: bool, code: string}
Builds the wire shape `{success, code}` of the `save-price-snapshot` / `delete-price-snapshot` response; the contract of the Api presenter (Admin bridge and external clients) stays unchanged.
softDelete()  : Result
Soft-deletes a set without cascade (a set is only itself; product and variant are deleted from their own direction, {@see ProductManager::softDelete()}, {@see ProductVariantManager::softDelete()}). Sets `deleted=1` instead of DELETE; the data stays for old orders.
updateAllRelations()  : Result
updateColumn()  : Result
updateColumns()  : Result
updateRelations()  : Result
afterSave()  : Result|null
Lazily creates the comment / rating threads.
assetCleanupRoots()  : array<int, string>
Asset roots inside which {@see removeEmptyAssetDirs()} may remove empty directories; never above them.
assetDir()  : string
Returns the asset directory from the server neon `params` (through Config), not from `getSettings()`: the shared setting context may be filled by a client without server paths (see the in-process ApiBridge).
assetIdOf()  : int|null
Normalizes the "current image" to an id. Channels with an ORM association return an entity (even a lazy proxy; `getId()` does not initialize it), channels with a scalar FK return an int.
assetVariantFilePaths()  : array<int, string>
Returns the absolute paths of the files of the asset just written (variants and the original).
beforeDelete()  : Result|null
Captures the set gallery before the cascade drops its relations. The preloaded entity from `DeleteTrait` does not carry `assetRels` (lazy), so the set is reloaded with the include.
beforeSave()  : Result|null
Validates the set before persist and fills the denormalized `price` sort column.
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()}.
buildVariantConfigs()  : array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>
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).
captureOwnedAssetIds()  : array<int, int>
Ids of the images the given entities own ({@see completedAssetCleanup()} requires the same opt-in `ownedAssetIds()`), captured before the rows are deleted. For paths that bypass {@see DeleteTrait::delete()} and its hooks, such as a subtree delete ({@see TreeTrait::deleteNode()}). Fail-soft: an error of one entity is logged and the others are processed.
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.
completedAssetCleanup()  : void
Post-commit cleanup of the assets of an entity that was just deleted.
completedDelete()  : void
Hard delete of sets (`DeleteTrait` hook, post-commit): deletes the documents from ES. The admin uses a soft delete (`deleted=1` through save, handled in `completedSave()`); this path covers a hard delete from the Api.
completedSave()  : void
Indexes the set into ES after the commit (fail-open).
createAssetRel()  : object
Builds the set-to-image relation with its order. `productSet` (owner FK) is not set: the mapper fills it from the parent id during `insertRelations` (DELETE+INSERT of relations).
createCommentThread()  : Result|null
Creates a new {@see CommentThread} and writes its id into `$column` of the entity with id `$entityId` (default `commentThreadId`, a scalar FK; pass the association name for an association). Returns a failed {@see Result} (the caller in `afterSave` may return it, which rolls back), or null when there is nothing to do or everything is fine.
createRatingThread()  : Result|null
Creates a new {@see RatingThread} of the given section and writes its id into `$column` of the entity `$entityId` (default `ratingThreadId`, a scalar FK; pass the association name for an association). Returns a failed {@see Result} (the caller may return it from `afterSave` to roll back), or null.
deleteAsset()  : void
Deletes an asset file by its absolute path.
deleteAssetFiles()  : void
Deletes the previously scanned files (see {@see listAssetFilesIn()}). Call only after the FK update to the new Asset succeeded.
dropForeignExistingItems()  : array<int, TItem>
From the `type: existing` items keeps only those whose id is already linked to the record `$parentId` through `$relationProperty` (read from the database). Other types (`new`) stay unchanged. When the links cannot be determined (`null`), no existing item passes: losing the order is better than attaching a foreign file.
failureOrNull()  : Result|null
A failed Result to propagate, success is null (the `afterSave` hook returns `?Result`).
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.
getAssetDirAbs()  : string
getAssetDirRel()  : string
getAssetFormatKinds()  : array<int, AssetFormatKind>
Media kinds allowed in the dropzone gallery of this manager. The default is images and video. Vector is left out on purpose: an end-user svg is needless exposure even when the sanitizer passes it; enable it explicitly if needed.
getAssetFormatRegistry()  : AssetFormatRegistry
Requires `$this->assetFormatRegistry` set by the decorator ({@see \App\Core\Base\Api\Models\Managers\Manager::setAssetFormatRegistry()}).
getAssetMaxBytes()  : int|null
Upper size limit of a new gallery file in bytes, `null` = no limit. The default has none; adverts and company profiles return {@see \App\Core\Security\Upload\ImageUploadSizeLimit::bytes()}.
getAssetSubDir()  : string
getAssetUsageKey()  : string|null
getAssetVariantType()  : string
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).
getMainAssetColumn()  : string
Name of the property the main (first) gallery image is written to. The default `asset` is an ORM ManyToOne to `Asset` (Message/Advert/Intro). Entities that keep the main image as a flat FK column without an association (Company `Reference::$imageId`) override the hook; otherwise `updateColumn()` would write to a nonexistent property.
getService()  : Service<string|int, TEntity>
Returns the service backing the manager traits.
listAssetFilesIn()  : array<int, string>
Lists the files lying in the target directory before a new image is written.
orderedAssetsFromMeta()  : array<int, array{type?: string, imageId?: int, name?: string, data?: string, path?: string, order?: int}>
Turns `meta['orderedAssets']` of the request into the list of dropzone items in the shape {@see processOrderedAssets()} reads. Unknown keys and values of a wrong type are dropped; a present but non-text `path` stays as `''` so the file read rejects it the same way instead of silently skipping it.
orderedFilesFromMeta()  : array<int, array{type: string, fileId?: int, name?: string, data?: string, path?: string, order?: int}>
Turns `meta['files']` of the request into the list of files for `processFiles()`/`processComplaintFiles()`.
ownedAssetIds()  : array<int, int|object|null>
Returns the images the set owns: the gallery from {@see beforeDelete()} plus the main photo (FK `asset`, normally the same image as the first gallery one). A shared image (used elsewhere) stays (`AssetManager::releaseAssetRow()`).
processMainImage()  : Result|null
Processes the main photo from `$meta` (sent by the admin form).
processOrderedAssets()  : Result|null
Shared template method for processing an ordered array of dropzone assets.
productImageVariantConfigs()  : array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>
Variants of product images, by the usage from `getAssetVariantType()` (`eshop-products` or `eshop-product-sets`) in the image variants table; product and set each have their own.
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_*`).
releaseAsset()  : bool
Releases the old asset after an image was cleared or replaced: deletes its `cms_system_images` row and files. Without this step every replacement or removal left an orphaned row.
releaseDroppedAssets()  : void
Releases the images that disappeared from the record after the relations were replaced (removed in the gallery or form). Call only after the relations and the main image were written successfully; a shared image (still referenced) is not deleted by {@see releaseAsset()}, and a cleanup error does not fail the save.
releaseOwnedAssets()  : void
Releases the captured images after the delete commit; a shared image stays ({@see releaseAsset()} → `AssetManager::releaseAssetRow()`), an error is only logged.
removeEmptyAssetDirs()  : void
Removes the directories the delete left empty (`galleries/20/{big,original,thumb}`, `intros/14/1/…`), walking up while a directory is empty and strictly inside one of the asset roots ({@see assetCleanupRoots()}).
resolveAssetExtension()  : string|null
Normalized extension of an uploaded file, or null when the format is not allowed in this context.
saveAsset()  : string|null
Saves one base64 image; `null` means the image did not pass {@see ImageUploadPolicy}.
saveAssets()  : array<int, string>
Saves base64 images into a directory, raster images only, per {@see ImageUploadPolicy}.
saveAssetVariants()  : Asset|null
Saves the files to disk (the format processor does the work) and creates the `Asset` entity with the variant plan and real dimensions. A variant `name` in the database equals the config key and the directory on disk.
staticImageVariantConfigs()  : array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>
Variants of category and parameter images, also by the usage from `getAssetVariantType()`.
storeImage()  : Asset|null
Stores an uploaded image as an `Asset` (variants) under `.../<parentId>/<variant>/`.
aggregateAvailability()  : SetAvailability
Aggregates availability over the leaves of one set: `availableSets = min(floor(available / need))`, `inStock` = all leaves in stock, `deliveryDays` = the maximum (the slowest leaf sets the deadline).
applyDenormSortPrice()  : void
Computes the denormalized listing sort price: the currently valid price (published <= now) for the base user group and default currency at the base quantity tier (from_amount <= 1).
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.
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.
columnsInvalidateCache()  : bool
decimal()  : string
Converts a money value to a `decimal(15,4)` string (columns `price` / `original_price`).
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.
fetchParameterValueCountsFromEs()  : array<int, array<string|int, int>>|null
ES branch of {@see fetchParameterValueCounts()}: a `terms` aggregation over the field `pv` of documents whose `productSetId` is among the DB-resolved visible ids.
fetchSupplyKindIds()  : array<int, int>
Returns the supply kind of each set (`cms_system_supply_kinds_supply_kind_id`, not null): raw DBAL, one query regardless of the set count.
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.
normalizeStoredTime()  : string
Normalizes a stored snapshot identity (`published_time` as in the DB = UTC) to `Y-m-d H:i:s`, with no timezone conversion ({@see deletePriceSnapshot()}).
nowUtc()  : string
Returns "now" as a UTC string for the price time columns.
processImages()  : Result|null
Builds the set gallery from `$meta['images']` (ordered admin form rows, drag&drop order).
recalcDenormPrice()  : Result
Recalculates and writes the denormalized `price` after a write into the price series (bridge, outside the generic save).
reject()  : Result
Rejects a save from `beforeSave()` and discards the half-built graph from the UnitOfWork.
resolveBestPrice()  : string|null
Picks the currently valid price: the highest base tier wins, on a tie the most recently published price. Shared by {@see applyDenormSortPrice()} (graph save hot path) and the snapshot bridge ({@see recalcDenormPrice()}), which need the same resolution.
resolveFacetLanguageAlias()  : string|null
Returns the language alias for choosing the per-language index; null means no usable alias and the caller falls back to the SQL aggregation.
resolveImageAsset()  : int|null
Returns the image id of a row: a new upload is stored as an asset (with variants), for an existing image its id is returned. Null means a row without a file and without an existing image (skip it).
resolvePublishedTime()  : string
Converts a `datetime-local` / any parsable string to `Y-m-d H:i:s` (ORM string in a PK); empty means now.
resolveSellerId()  : int|null
Returns the e-shop seller from the settings chain `4/invoicer.sellerId` to `7/invoice.default.sellerId` (the same chain as checkout, {@see OrderCheckoutManager}).
rollbackBatch()  : void
saveWithHooks()  : Result
Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.
snapshotOutcome()  : Result
Wraps the domain outcome of a price operation as a {@see Result} (`data['code']` is the result code).
todayMidnight()  : string
Returns today's midnight (Prague calendar day, the immutability boundary of the history) as a UTC string, so it compares lexicographically with `$targetTime` / `published_time` (both UTC, see {@see nowUtc()}).
usesAssetPathTrait()  : bool
validateComposition()  : Result|null
Validates the composition invariants of the set:

Constants

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().

ESHOP_SETTINGS_MODULE

Eshop module index (image directories `assetDir(4, ...)` in managers using this trait).

private int ESHOP_SETTINGS_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.

$defaultSkipFields

Fields stripped from the payload before hydration.

protected array<string|int, string> $defaultSkipFields = ['assetRels', 'prices']

assetRels (gallery join entity ProductSetImageRel with order) is rebuilt in afterSave() from $meta['images'], not by the object graph: a row carries an uploaded file (base64) from which the asset is created. Stripping keeps the generic save from extracting an (typically empty) collection and wiping the relations through orphanRemoval.

prices is an append-only time series with orphanRemoval. The object-graph save sends only the rows the form knows, so a set edited without includes: ['prices'] would lose its price history irreversibly. stripDefaultSkipFields() removes the key before hydration on both save branches (see SaveTrait::stripDefaultSkipFields()), so the graph save never touches the collection. The only write channel is the bridge savePriceSnapshot() (updateColumn, full replace).

$graphDataKeys

An external client may send `images` nested in `data`; `internalActionSave()` moves them to `meta`, where {@see processImages()} reads them unchanged.

protected array<string|int, string> $graphDataKeys = ['images']

$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.

$lastAssetRejectionCode

Why the last {@see saveAssetVariants()} returned `null` ({@see UploadRejection} `REASON_*`); the caller passes it to the report of rejected items. `null` means the last save passed.

protected string|null $lastAssetRejectionCode = null

$nonInvalidatingColumns

Columns whose update does not invalidate the cache: view counters (`registerEvent()` bumps the counter on every pageview). Without this every view would flush the query cache of the whole class; counters are therefore stale up to the TTL. Override per manager by redefining the property.

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

Covers all view counters across modules: views (Blog Article), viewsList (list impression of Advert/Message) and viewsDetail (detail view of Advert/Message, registerEvent() in the *DetailTraits). IMPORTANT: viewsDetail must stay in the list, otherwise every open of an advert/post detail would invalidate all lists of the class.

$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

$ownedGalleryAssets

Gallery image ids of the sets being deleted, captured by {@see beforeDelete()}; after the DELETE (CASCADE on `..._image_relations`) nobody can find them.

private WeakMap<object, array<int, int>>|null $ownedGalleryAssets = null

Keyed by the entity instance DeleteTrait hands to every hook, not by its id: Doctrine nulls the id of a removed instance on flush, so ownedAssetIds() after the commit cannot look the set up by id.

$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(ProductSetService $service, CommentThreadManager $commentThreadManager, RatingThreadManager $ratingThreadManager, UserGroupManager $userGroupManager, CurrencyManager $currencyManager, LanguageManager $languageManager, EntityManagerInterface $em, UnitOfWorkReset $unitOfWorkReset, SetCompositionService $composition, PriceResolver $priceResolver, TaxManager $taxManager, StockItemManager $stockItemManager, WarehouseManager $warehouseManager, AssetManager $assetManager, AssetVariantProvider $variantProvider, PriceRecalculator $priceRecalculator, EsEntityIndexer $esIndexer, ProductSetDocumentSource $esDocumentSource, EsSearcher $esSearcher, SupplyKindManager $supplyKindManager) : mixed
Parameters
$service : ProductSetService
$commentThreadManager : CommentThreadManager
$ratingThreadManager : RatingThreadManager
$userGroupManager : UserGroupManager
$currencyManager : CurrencyManager
$languageManager : LanguageManager
$em : EntityManagerInterface
$unitOfWorkReset : UnitOfWorkReset
$composition : SetCompositionService
$priceResolver : PriceResolver
$taxManager : TaxManager
$stockItemManager : StockItemManager
$warehouseManager : WarehouseManager
$assetManager : AssetManager
$variantProvider : AssetVariantProvider
$priceRecalculator : PriceRecalculator
$esIndexer : EsEntityIndexer
$esDocumentSource : ProductSetDocumentSource
$esSearcher : EsSearcher
$supplyKindManager : SupplyKindManager

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()

Bulk hard delete by filters goes through {@see delete()}: the generic bulk (`DeleteTrait::deleteAll()`) does not run lifecycle hooks and would bypass the history gate.

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

deletePriceSnapshot()

Deletes a whole price snapshot (all rows of the given `publishedTime`), only today or the future. Returns a {@see Result} like {@see savePriceSnapshot()} (shares `snapshotOutcome()`).

public deletePriceSnapshot(int $id, string $publishedTime) : Result
Parameters
$id : int
$publishedTime : string
Return values
Result

deleteThreads()

Deletes the comment and rating threads of vanished sets (the link goes from the set to the thread, so the threads with their comments and ratings would stay orphaned). Called inside the delete transaction, before the set rows are deleted, while the thread ids are still known.

public deleteThreads(array<int, int> $commentThreadIds, array<int, int> $ratingThreadIds) : Result
Parameters
$commentThreadIds : array<int, int>
$ratingThreadIds : array<int, int>
Return values
Result

fetchParameterValueCounts()

Counts how many sets of the given collection have each value of each parameter; basis for the filter option counts and for the list of parameters that occur in the listing.

public fetchParameterValueCounts(array<int, object> $filters) : array<int, array<string|int, int>>

One aggregate query instead of hydrating the whole catalog with parameterRels.parameter.

IMPORTANT: visibility is not written in SQL here. The input is the same filters that build the listing (ProductSetEligibilityFilters, category, active facets), the single source of truth; a private copy of the rules in SQL would silently diverge from the listing on the first change.

The GROUP BY runs over the generated column value_h (MD5(value), varbinary(16)) and the composite index ix_facet (parameter_id, value_h, product_set_id), not over the plain text value. value_h serves only this aggregation and the equality filter; it is not mapped in the Doctrine entity (generated column, the ORM would try to insert into it) and ParameterRelFilter / ParameterRelRangeFilter do not use it.

The readable value is resolved in the same SQL query in two logical steps: a derived table (step 1: GROUP BY value_h, value_h is not null, no value column, index-only) and a correlated subquery on value (step 2: point lookup through ix_facet per group, LIMIT 1, server-side).

IMPORTANT: do not split it into two queries with the pairs joined in PHP. Above ~100 pairs in a row IN() MariaDB drops ix_facet and scans the whole table, and the second network round-trip costs more than step 1 saves. MIN(value) in one query is no alternative either: it sends the index back to the row for every group and loses the index-only group-by.

value <> '' in the subquery excludes the empty string (its value_h is not null, MD5('') is a valid hash). A group without a match (only null/empty values) yields value is null and PHP drops it. MD5 collisions are ignored: the same hash means the same value.

Parameters
$filters : array<int, object>

listing filters (eligibility + category + active facets)

Return values
array<int, array<string|int, int>> —

parameterId => [value => number of sets]

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()

Lists sets; ordering by price is done in the display currency.

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

The front end sends the key OrderByHelper::currentPriceField() with the currency only; the base price group (the same as for the displayed price, fetchCurrentPrices(userGroupId: null)) is added here and the repository translates it to a subquery (ProductSetRepository::applyOrdering()).

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

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

findHistoryBlockers()

Returns the reasons why sets cannot be hard-deleted: an order item, complaint or gift references them (an FK with `SET NULL` would silently detach the history). An empty array means they can be deleted.

public findHistoryBlockers(array<int, int> $setIds) : array<int, string>

Writes nothing; raw DBAL (no find(): every Api read runs UoW::clear()). Also used by ProductHardDeleteManager::findBlockers().

Parameters
$setIds : array<int, int>
Return values
array<int, string>

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)

getAvailabilities()

Returns the availability of sets for the catalog; the SQL count is constant, independent of the set count.

public getAvailabilities(array<string|int, int> $setIds[, int|null $languageId = null ]) : array<int, SetAvailability>

Stock is read only through the Store facades StockItemManager / WarehouseManager.

Parameters
$setIds : array<string|int, int>
$languageId : int|null = null
Return values
array<int, SetAvailability> —

keyed by setId (only existing sets)

getAvailability()

Returns the availability of one set for the catalog; see {@see getAvailabilities()} for batches.

public getAvailability(int $setId[, int|null $languageId = null ]) : SetAvailability|null
Parameters
$setId : int
$languageId : int|null = null
Return values
SetAvailability|null

getComposition()

Decomposes one set into its leaf stock items; see {@see getCompositions()} for an independent batch.

public getComposition(int $setId[, int $quantity = 1 ]) : SetDecomposition
Parameters
$setId : int
$quantity : int = 1
Return values
SetDecomposition

getCompositionMany()

Decomposes N sets summed together (one reservation/order); {@see getCompositions()} decomposes N sets independently of each other (catalog).

public getCompositionMany(array<int, int> $quantities) : SetDecomposition
Parameters
$quantities : array<int, int>

setId => quantity

Return values
SetDecomposition

getCompositions()

Decomposes N sets independently of each other with one shared set of SQL queries; consumed by {@see getAvailability()} below and by checkout.

public getCompositions(array<int, int> $quantities) : array<int, SetDecomposition>
Parameters
$quantities : array<int, int>

setId => quantity

Return values
array<int, SetDecomposition> —

keyed by setId

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>

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

recountPrices()

Self-heals the denormalized sort column `product_sets.price` from the view `current_prices` (cron).

public recountPrices() : int

Delegates to PriceRecalculator::recalcSortPrices(). The base group and currency are read here (a manager may use another manager) and passed to the service as parameters; the service must not inject a foreign manager.

Return values
int —

number of affected ProductSet rows

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

reindexSearch()

Indexes sets after a bulk write with `suppressSearchIndex` (product import): the same channel as {@see completedSave()}, in batches. Fail-open (the indexer logs and opens the circuit breaker itself); a disabled source is a no-op.

public reindexSearch(array<int, int> $setIds) : void
Parameters
$setIds : array<int, int>

releaseDeletedSetAssets()

Releases the images of vanished sets after the commit of a hard delete of a variant ({@see ProductVariantManager}). This manager owns the set images, so the release goes through it (a shared image stays).

public releaseDeletedSetAssets(array<int, int> $assetIds) : void
Parameters
$assetIds : array<int, int>

removeFromSearchIndex()

Deletes set documents from ES after their hard delete outside {@see delete()} (product delete cascade, {@see ProductHardDeleteManager}). Fail-open like all indexing.

public removeFromSearchIndex(array<int, int> $setIds) : void
Parameters
$setIds : array<int, int>

resolveDefaultDisplayTreatment()

Resolves the display tax treatment of the default supply kind (`SupplyKindManager::findFallbackId()`) in the given payment region; basis for {@see \App\UI\Api\Eshop\Presenters\ProductSetPresenter::internalActionPriceDisplay()} (a summary, not per set).

public resolveDefaultDisplayTreatment(int|null $paymentRegionId[, DateTimeInterface|null $at = null ]) : array{chargeVat: bool, vatPercents: string, noteKey: ?string, code: string}
Parameters
$paymentRegionId : int|null
$at : DateTimeInterface|null = null
Return values
array{chargeVat: bool, vatPercents: string, noteKey: ?string, code: string}

resolveDisplayTaxes()

Resolves the display tax treatment of each set: one batch query for the supply kind (raw DBAL, `cms_system_supply_kinds_supply_kind_id`) and one {@see TaxManager::resolveDisplay()} call for all distinct supply kinds, not per set.

public resolveDisplayTaxes(array<string|int, int> $productSetIds, int|null $paymentRegionId[, DateTimeInterface|null $at = null ]) : array<int, array{supplyKindId: int, chargeVat: bool, vatPercents: string, noteKey: ?string, code: string}>

A missing seller (settings chain 4/invoicer.sellerId to 7/invoice.default.sellerId) or an engine without a result for a supply kind gives chargeVat=false, vatPercents='0.00': never claim "incl. VAT" blindly.

Parameters
$productSetIds : array<string|int, int>
$paymentRegionId : int|null
$at : DateTimeInterface|null = null
Return values
array<int, array{supplyKindId: int, chargeVat: bool, vatPercents: string, noteKey: ?string, code: string}> —

keyed by productSetId; sets without a found supply kind are absent

resolveNextPriceTimes()

Returns the nearest future `published_time` of the price of each given set, in batch (countdown of the weekly deals section, {@see \App\UI\Api\Eshop\Presenters\ProductSetPresenter::internalActionNextPriceTimes()}).

public resolveNextPriceTimes(array<string|int, int> $productSetIds[, int|null $currencyId = null ]) : array<int, string>

A set without a scheduled future price (in the given currency) is absent from the result.

Parameters
$productSetIds : array<string|int, int>
$currencyId : int|null = null
Return values
array<int, string> —

setId => nearest future published_time

resolvePrice()

Returns the currently valid selling price of one set; see {@see resolvePrices()} for batches.

public resolvePrice(int $productSetId, int $userGroupId, int $currencyId[, int $amount = 1 ][, DateTimeInterface|null $at = null ]) : ProductSetPriceResult|null
Parameters
$productSetId : int
$userGroupId : int
$currencyId : int
$amount : int = 1
$at : DateTimeInterface|null = null
Return values
ProductSetPriceResult|null

resolvePrices()

Resolves catalog prices for many sets with one SQL query, regardless of the set count.

public resolvePrices(array<string|int, int> $productSetIds, int $userGroupId, int $currencyId[, int $amount = 1 ][, DateTimeInterface|null $at = null ]) : array<int, ProductSetPriceResult>
Parameters
$productSetIds : array<string|int, int>
$userGroupId : int
$currencyId : int
$amount : int = 1
$at : DateTimeInterface|null = null
Return values
array<int, ProductSetPriceResult> —

keyed by productSetId

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

savePriceSnapshot()

Saves or replaces a price snapshot.

public savePriceSnapshot(int $id, string|null $publishedTime, array<int, array{fromAmount: int, userGroupId: int, currencyId: int, price: string|float, originalPrice: string|float|null, points: int}> $rows[, string|null $replaceTime = null ]) : Result

replaceTime is the publishedTime of the original snapshot when editing (null = new): its rows are removed from the history and replaced by the given ones. The immutability guard applies to both times (no writes into the past); a collision of the target date with a different existing snapshot is a validation error, not a silent merge.

IMPORTANT: returns a Result, which is always truthy: callers must check ->isSuccess(). The domain code (OK/NOT_FOUND/IMMUTABLE/TIME_COLLISION/VALIDATION/DENORM) is in message and data['code']; the wire shape {success, code} for Admin/external clients is built by snapshotWire() (Api presenter).

Parameters
$id : int
$publishedTime : string|null
$rows : array<int, array{fromAmount: int, userGroupId: int, currencyId: int, price: string|float, originalPrice: string|float|null, points: int}>
$replaceTime : string|null = null
Return values
Result

setParamsConfig()

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

snapshotWire()

Builds the wire shape `{success, code}` of the `save-price-snapshot` / `delete-price-snapshot` response; the contract of the Api presenter (Admin bridge and external clients) stays unchanged.

public static snapshotWire(Result $result) : array{success: bool, code: string}
Parameters
$result : Result
Return values
array{success: bool, code: string}

softDelete()

Soft-deletes a set without cascade (a set is only itself; product and variant are deleted from their own direction, {@see ProductManager::softDelete()}, {@see ProductVariantManager::softDelete()}). Sets `deleted=1` instead of DELETE; the data stays for old orders.

public softDelete(int $id) : Result

NOTE: a soft-deleted set stays in existing bundle compositions (FK intact).

Parameters
$id : int
Return values
Result

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

afterSave()

Lazily creates the comment / rating threads.

protected afterSave(bool $isInsert, ProductSet &$entity[, Result|null $result = null ][, array<string, mixed> &$meta = [] ]) : Result|null

Existence is checked on the freshly loaded managed entity (authoritative FK from the DB), so this covers both insert and self-heal of legacy rows, and a normal edit (FK already set) creates nothing. The link is an updateColumn on the association name.

Then the gallery is processed from $meta['images'], see processImages().

Parameters
$isInsert : bool
$entity : ProductSet
$result : Result|null = null
$meta : array<string, mixed> = []
Return values
Result|null

assetCleanupRoots()

Asset roots inside which {@see removeEmptyAssetDirs()} may remove empty directories; never above them.

protected assetCleanupRoots() : array<int, string>

The gallery root by default. A manager writing another channel into its own root (a company logo next to the gallery, an advert attachment next to the images) lists that root too, otherwise the directories of that channel stayed after a delete (admin walkthrough B8).

Return values
array<int, string> —

absolute directories

assetDir()

Returns the asset directory from the server neon `params` (through Config), not from `getSettings()`: the shared setting context may be filled by a client without server paths (see the in-process ApiBridge).

protected assetDir(int $module, string $key, string $type) : string

An empty path is a configuration error and throws loudly instead of silently writing to a relative path.

Requires $this->config on the manager (BaseManager, set by the decorator).

Parameters
$module : int

module in params (1=System, 3=Blog, 5=Bazaar, 6=Comcat, 8=Discussion)

$key : string

directory key (advertDir, categoryDir, imgDir...)

$type : string

abs or rel

Return values
string

assetIdOf()

Normalizes the "current image" to an id. Channels with an ORM association return an entity (even a lazy proxy; `getId()` does not initialize it), channels with a scalar FK return an int.

protected assetIdOf(mixed $current) : int|null
Parameters
$current : mixed
Return values
int|null

assetVariantFilePaths()

Returns the absolute paths of the files of the asset just written (variants and the original).

protected assetVariantFilePaths(string $absBase, string $fileName, array<string, mixed> $variantConfigs) : array<int, string>

Variants are written to disk inside the transaction, before it is certain the change holds, and rollback() knows nothing about files. On a failed path (a Result failure after which SaveTrait rolls back) the caller therefore deletes what it just wrote (see the orphan variant cleanup in app/UI/Admin/Eshop/Components/ProductSetEditComponent/Traits/FormTrait.php).

The search is by $fileName (12 random characters), so files of another asset in the same directory are never touched.

Parameters
$absBase : string
$fileName : string
$variantConfigs : array<string, mixed>

key = variant name = subdirectory

Return values
array<int, string>

beforeDelete()

Captures the set gallery before the cascade drops its relations. The preloaded entity from `DeleteTrait` does not carry `assetRels` (lazy), so the set is reloaded with the include.

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

Cleanup is done post-commit by AssetProcessingTrait::completedAssetCleanup() through ownedAssetIds(). A soft delete (softDelete(), used by the admin) does not get here and keeps the images, since the set rows stay for old orders.

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

beforeSave()

Validates the set before persist and fills the denormalized `price` sort column.

protected beforeSave(bool $isInsert, ProductSet &$entity[, array<string, mixed> &$meta = [] ]) : Result|null
  • typeId must be 1 (product) or 2 (bundle);
  • code must be unique (raw DBAL — no manager->findBy in hooks);
  • composition invariants — see validateComposition();
  • price (denorm, for listing sort) is recomputed from the in-memory price graph as a hot-path best-effort; /cron/eshop/price/recalc is the authoritative self-heal.
Parameters
$isInsert : bool
$entity : ProductSet
$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

buildVariantConfigs()

protected buildVariantConfigs() : array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>
Return values
array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>

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> = []

captureOwnedAssetIds()

Ids of the images the given entities own ({@see completedAssetCleanup()} requires the same opt-in `ownedAssetIds()`), captured before the rows are deleted. For paths that bypass {@see DeleteTrait::delete()} and its hooks, such as a subtree delete ({@see TreeTrait::deleteNode()}). Fail-soft: an error of one entity is logged and the others are processed.

protected captureOwnedAssetIds(iterable<string|int, object> $entities) : array<int, int>
Parameters
$entities : iterable<string|int, object>
Return values
array<int, int>

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>

completedAssetCleanup()

Post-commit cleanup of the assets of an entity that was just deleted.

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

DeleteTrait::delete() calls it next to completedDelete() but under its own name, on purpose: many Api managers define completedDelete() themselves (e.g. Blog ArticleManager, Bazaar/Comcat AdvertManager, Discussion MessageManager, Eshop ProductSetManager) and a class method overrides a trait method without any warning, so the shared cleanup would silently vanish in them.

Opt-in: the hook runs only for managers that define ownedAssetIds(object $entity): array, i.e. that can tell which assets the entity owns. A generic hook cannot guess it, because the FK carrier differs (asset / imageId / logoId / photoImageId / a relation collection for a gallery).

Post-commit is right here (unlike releaseAsset() on image replacement): the entity is already deleted, its FK is gone (SET NULL/CASCADE) and the cleanup must not resurrect it. Inside the transaction it would still see the FK alive and never release the asset.

Fail-soft: the whole body is in try/catch and returns nothing; a failed cleanup must not bring back the deleted entity.

IMPORTANT: a shared asset is not judged here; AssetManager::releaseAssetRow() decides, and when anything still points to the asset it stays with its files (releaseAsset() returns false).

IMPORTANT: a soft delete does not get here. It goes through save(), not delete(), so this hook does not run, and it must not: a soft-deleted record can be restored and would come back without its image.

Parameters
$entities : array<int, object>

entities loaded before the delete (DeleteTrait holds them)

$result : Result
$meta : array<string, mixed> = []

completedDelete()

Hard delete of sets (`DeleteTrait` hook, post-commit): deletes the documents from ES. The admin uses a soft delete (`deleted=1` through save, handled in `completedSave()`); this path covers a hard delete from the Api.

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

completedSave()

Indexes the set into ES after the commit (fail-open).

protected completedSave(bool $isInsert, ProductSet &$entity[, Result|null $result = null ][, array<string, mixed> &$meta = [] ]) : void

Deliberately not in afterSave(): that runs inside the transaction and updateColumn still changes the DB afterwards (gallery, threads); the indexer reads the set fresh itself (fetchFresh, useCache: false). An empty buildDocuments() (bundle / deleted / deactivated card) deletes the card's documents, which also covers the admin delete (a soft delete goes through save).

Parameters
$isInsert : bool
$entity : ProductSet
$result : Result|null = null
$meta : array<string, mixed> = []

createAssetRel()

Builds the set-to-image relation with its order. `productSet` (owner FK) is not set: the mapper fills it from the parent id during `insertRelations` (DELETE+INSERT of relations).

protected createAssetRel(object $parent, Asset $asset, int $order, bool $isMain) : object
Parameters
$parent : object
$asset : Asset
$order : int
$isMain : bool
Return values
object

createCommentThread()

Creates a new {@see CommentThread} and writes its id into `$column` of the entity with id `$entityId` (default `commentThreadId`, a scalar FK; pass the association name for an association). Returns a failed {@see Result} (the caller in `afterSave` may return it, which rolls back), or null when there is nothing to do or everything is fine.

protected createCommentThread(int|null $entityId, ThreadType $type[, string $column = 'commentThreadId' ]) : Result|null
Parameters
$entityId : int|null
$type : ThreadType
$column : string = 'commentThreadId'
Return values
Result|null

createRatingThread()

Creates a new {@see RatingThread} of the given section and writes its id into `$column` of the entity `$entityId` (default `ratingThreadId`, a scalar FK; pass the association name for an association). Returns a failed {@see Result} (the caller may return it from `afterSave` to roll back), or null.

protected createRatingThread(int|null $entityId, ThreadType $type[, string $column = 'ratingThreadId' ]) : Result|null
Parameters
$entityId : int|null
$type : ThreadType
$column : string = 'ratingThreadId'
Return values
Result|null

deleteAsset()

Deletes an asset file by its absolute path.

protected deleteAsset(string|null $absolutePath) : void
Parameters
$absolutePath : string|null

Full absolute path to the file

deleteAssetFiles()

Deletes the previously scanned files (see {@see listAssetFilesIn()}). Call only after the FK update to the new Asset succeeded.

protected deleteAssetFiles(array<int, string> $paths) : void
Parameters
$paths : array<int, string>

dropForeignExistingItems()

From the `type: existing` items keeps only those whose id is already linked to the record `$parentId` through `$relationProperty` (read from the database). Other types (`new`) stay unchanged. When the links cannot be determined (`null`), no existing item passes: losing the order is better than attaching a foreign file.

protected dropForeignExistingItems(int $parentId, array<int, TItem> $items, string $relationProperty, string $targetAssociation, string $idKey) : array<int, TItem>

Shared by images (assetRels/asset/imageId) and advert files (fileRels/file/fileId, AdvertAssetTrait::processFiles()).

Parameters
$parentId : int
$items : array<int, TItem>
$relationProperty : string
$targetAssociation : string
$idKey : string
Tags
template
Return values
array<int, TItem>

failureOrNull()

A failed Result to propagate, success is null (the `afterSave` hook returns `?Result`).

protected failureOrNull(Result $result) : Result|null
Parameters
$result : Result
Return values
Result|null

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()

getAssetFormatKinds()

Media kinds allowed in the dropzone gallery of this manager. The default is images and video. Vector is left out on purpose: an end-user svg is needless exposure even when the sanitizer passes it; enable it explicitly if needed.

protected getAssetFormatKinds() : array<int, AssetFormatKind>
Return values
array<int, AssetFormatKind>

getAssetMaxBytes()

Upper size limit of a new gallery file in bytes, `null` = no limit. The default has none; adverts and company profiles return {@see \App\Core\Security\Upload\ImageUploadSizeLimit::bytes()}.

protected getAssetMaxBytes() : int|null
Return values
int|null

getAssetSubDir()

protected getAssetSubDir(int $parentId) : string
Parameters
$parentId : int
Return values
string

getAssetUsageKey()

protected getAssetUsageKey() : string|null
Return values
string|null

getAssetVariantType()

protected getAssetVariantType() : string
Return values
string

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

getMainAssetColumn()

Name of the property the main (first) gallery image is written to. The default `asset` is an ORM ManyToOne to `Asset` (Message/Advert/Intro). Entities that keep the main image as a flat FK column without an association (Company `Reference::$imageId`) override the hook; otherwise `updateColumn()` would write to a nonexistent property.

protected getMainAssetColumn() : string
Return values
string

getService()

Returns the service backing the manager traits.

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

listAssetFilesIn()

Lists the files lying in the target directory before a new image is written.

protected listAssetFilesIn(string $absBase) : array<int, string>

Single-image channels (company logo, price list group photo) delete the old file. Deleting it before the new Asset is saved and the FK updated is a trap: if anything later fails, the database still points to the old Asset whose files no longer exist. So the old state is only scanned and deleted after success (see deleteAssetFiles()). Name collisions cannot happen because saveAssetVariants() generates a random fileName, so the new image lies next to the old one.

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

absolute paths

orderedAssetsFromMeta()

Turns `meta['orderedAssets']` of the request into the list of dropzone items in the shape {@see processOrderedAssets()} reads. Unknown keys and values of a wrong type are dropped; a present but non-text `path` stays as `''` so the file read rejects it the same way instead of silently skipping it.

protected static orderedAssetsFromMeta(mixed $value) : array<int, array{type?: string, imageId?: int, name?: string, data?: string, path?: string, order?: int}>
Parameters
$value : mixed
Return values
array<int, array{type?: string, imageId?: int, name?: string, data?: string, path?: string, order?: int}>

orderedFilesFromMeta()

Turns `meta['files']` of the request into the list of files for `processFiles()`/`processComplaintFiles()`.

protected static orderedFilesFromMeta(mixed $value) : array<int, array{type: string, fileId?: int, name?: string, data?: string, path?: string, order?: int}>

An item without a text type is dropped (processing cannot place it anyway); other keys as in orderedAssetsFromMeta().

Parameters
$value : mixed
Return values
array<int, array{type: string, fileId?: int, name?: string, data?: string, path?: string, order?: int}>

ownedAssetIds()

Returns the images the set owns: the gallery from {@see beforeDelete()} plus the main photo (FK `asset`, normally the same image as the first gallery one). A shared image (used elsewhere) stays (`AssetManager::releaseAssetRow()`).

protected ownedAssetIds(object $entity) : array<int, int|object|null>
Parameters
$entity : object
Return values
array<int, int|object|null>

processMainImage()

Processes the main photo from `$meta` (sent by the admin form).

protected processMainImage(int $parentId, array<string, mixed> $meta[, int|object|null $currentAsset = null ]) : Result|null
  • image = ['name', 'data' (base64)] builds an Asset (variants) and sets the asset FK.
  • imageDelete = true (without a new image) clears the FK.
  • A missing key leaves the image unchanged.

IMPORTANT: the caller must pass $currentAsset (the image the FK points at now, entity or id); the trait has only $parentId. Without it the old Asset is not released after a replace or removal and stays orphaned.

Parameters
$parentId : int
$meta : array<string, mixed>
$currentAsset : int|object|null = null
Return values
Result|null

processOrderedAssets()

Shared template method for processing an ordered array of dropzone assets.

protected processOrderedAssets(object $entity, array<int, array{type?: string, imageId?: int, name?: string, data?: string, path?: string, order?: int}> $orderedAssets) : Result|null

IMPORTANT: every database step (soft-remove rels, full replace of rels, main image FK) returns a Result; a failure is returned to the caller and the next step is skipped. The caller (afterSave) must pass it on so that SaveTrait::save() rolls back the whole save; otherwise a gallery error is dropped and the admin gets success with an unchanged or half-changed gallery. null means the gallery is unchanged or everything passed.

Parameters
$entity : object
$orderedAssets : array<int, array{type?: string, imageId?: int, name?: string, data?: string, path?: string, order?: int}>
Return values
Result|null

productImageVariantConfigs()

Variants of product images, by the usage from `getAssetVariantType()` (`eshop-products` or `eshop-product-sets`) in the image variants table; product and set each have their own.

protected productImageVariantConfigs() : array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>
Return values
array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>

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

releaseAsset()

Releases the old asset after an image was cleared or replaced: deletes its `cms_system_images` row and files. Without this step every replacement or removal left an orphaned row.

protected releaseAsset(int|object|null $current[, int|null $keepAssetId = null ]) : bool

IMPORTANT: call only after a successful updateColumn(), i.e. when the FK no longer points to $assetId.

Shared assets: this method judges nothing, AssetManager::releaseAssetRow() decides; it derives the references from information_schema and deletes nothing when in doubt.

It runs inside the save transaction of the caller (afterSave), not post-commit:

  1. the row delete is atomic with clearing or switching the FK; if anything later in afterSave fails, the rollback restores both, while a post-commit variant is irreversible,
  2. the reference check runs on the same connection, so it sees the FK just written (a post-commit hook would have to read again and could miss another edit),
  3. several affected managers already define the post-commit completedSave() themselves (Blog ArticleManager, Discussion MessageManager, Eshop ProductSetManager), so a trait version would silently not run in them. Files are deleted right after the row, where deleteAssetFiles($stalePaths) already stands for a replacement, so no new window appears.

Fail-soft: any cleanup error is only logged (channel assets) and the main save does not fail; an orphaned row is a smaller defect than a broken edit.

Parameters
$current : int|object|null

old FK (id or Asset entity); null or 0 means nothing to release

$keepAssetId : int|null = null

id that must not be released (the newly written asset)

Return values
bool

releaseDroppedAssets()

Releases the images that disappeared from the record after the relations were replaced (removed in the gallery or form). Call only after the relations and the main image were written successfully; a shared image (still referenced) is not deleted by {@see releaseAsset()}, and a cleanup error does not fail the save.

protected releaseDroppedAssets(array<int, int> $previousAssetIds, array<int, int> $keptAssetIds) : void
Parameters
$previousAssetIds : array<int, int>

images of the record before the save

$keptAssetIds : array<int, int>

images the record holds after the save

releaseOwnedAssets()

Releases the captured images after the delete commit; a shared image stays ({@see releaseAsset()} → `AssetManager::releaseAssetRow()`), an error is only logged.

protected releaseOwnedAssets(array<int, int> $assetIds) : void
Parameters
$assetIds : array<int, int>

removeEmptyAssetDirs()

Removes the directories the delete left empty (`galleries/20/{big,original,thumb}`, `intros/14/1/…`), walking up while a directory is empty and strictly inside one of the asset roots ({@see assetCleanupRoots()}).

protected removeEmptyAssetDirs(array<int, string> $dirs) : void

Without it every deleted gallery or intro left its empty per-record folders behind. The root itself and anything outside it are never touched; a directory that is not empty (another asset, a race with a new upload) simply stays, rmdir() refuses it. A failure is logged, never thrown.

Parameters
$dirs : array<int, string>

directories of the deleted files

resolveAssetExtension()

Normalized extension of an uploaded file, or null when the format is not allowed in this context.

protected resolveAssetExtension(string $fileName[, array<int, AssetFormatKind> $kinds = [AssetFormatKind::Raster] ]) : string|null

Replaces copied whitelists with a silent fallback to jpg, which would save an svg under the extension .jpg with xml content.

Parameters
$fileName : string
$kinds : array<int, AssetFormatKind> = [AssetFormatKind::Raster]
Return values
string|null

saveAsset()

Saves one base64 image; `null` means the image did not pass {@see ImageUploadPolicy}.

protected saveAsset(array<string|int, mixed> $asset, string $dir) : string|null
Parameters
$asset : array<string|int, mixed>

['name' => 'filename.jpg', 'data' => 'base64...']

$dir : string

Absolute path to target directory

Return values
string|null

saveAssets()

Saves base64 images into a directory, raster images only, per {@see ImageUploadPolicy}.

protected saveAssets(array<string|int, mixed> $assets, string $dir) : array<int, string>

IMPORTANT: the type is detected from the content, a decompression bomb is rejected by its header and the image is re-saved through GD (orientation applied, metadata stripped). Writing raw bytes with the extension from the name would let a new internal caller store an svg with a script and keep EXIF (including GPS) in every photo. A rejected file is skipped (written to the assets log).

Parameters
$assets : array<string|int, mixed>

[['name' => 'filename.jpg', 'data' => 'base64...'], ...] (from the Api meta; the shape is verified here)

$dir : string

Absolute path to target directory

Return values
array<int, string> —

saved file names (without the rejected ones)

saveAssetVariants()

Saves the files to disk (the format processor does the work) and creates the `Asset` entity with the variant plan and real dimensions. A variant `name` in the database equals the config key and the directory on disk.

protected saveAssetVariants(string $rawData, string $extension, string $absBase, string $relBase, array<string, array{maxWidth: int, maxHeight: int, resizeFlags: array, useWatermark: bool}> $variantConfigs[, array<int, AssetFormatKind> $kinds = [AssetFormatKind::Raster] ]) : Asset|null

The variant plan is always stored, even when the variants are not on disk yet (video without ffmpeg), so the catch-up cron knows what to add; reality is held by the flag thumbnails_generated.

Returns null when the format is unsupported or the processor rejected it (corrupt image, svg that failed sanitization). The caller skips such a file.

Parameters
$rawData : string
$extension : string
$absBase : string
$relBase : string
$variantConfigs : array<string, array{maxWidth: int, maxHeight: int, resizeFlags: array, useWatermark: bool}>
$kinds : array<int, AssetFormatKind> = [AssetFormatKind::Raster]

media kinds allowed in this context

Return values
Asset|null

staticImageVariantConfigs()

Variants of category and parameter images, also by the usage from `getAssetVariantType()`.

protected staticImageVariantConfigs() : array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>
Return values
array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>

storeImage()

Stores an uploaded image as an `Asset` (variants) under `.../<parentId>/<variant>/`.

protected storeImage(int $parentId, array<string|int, mixed> $item, array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}> $variantConfigs[, bool $purgeDir = false ][, array<int, string>|null &$stalePaths = null ]) : Asset|null

$purgeDir = true for a single main image (category/product): old files in the target directory are listed in the output $stalePaths after a successful replace, but this method never deletes them. The caller deletes them only after the FK write to the new Asset succeeds, otherwise a failure would leave the DB pointing at missing files. Name collisions cannot happen: saveAssetVariants() generates a random fileName.

IMPORTANT: for a gallery (several images per parent) $purgeDir must be false, otherwise saving the second image would mark the first one's files as stale.

Parameters
$parentId : int
$item : array<string|int, mixed>
$variantConfigs : array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>
$purgeDir : bool = false
$stalePaths : array<int, string>|null = null

output parameter: absolute paths of files that were in the directory before the write

Tags
param-out

list $stalePaths

Return values
Asset|null

aggregateAvailability()

Aggregates availability over the leaves of one set: `availableSets = min(floor(available / need))`, `inStock` = all leaves in stock, `deliveryDays` = the maximum (the slowest leaf sets the deadline).

private aggregateAvailability(int $setId, SetDecomposition $decomposition, array<int, ItemAvailability> $itemAvailabilities) : SetAvailability
Parameters
$setId : int
$decomposition : SetDecomposition
$itemAvailabilities : array<int, ItemAvailability>
Return values
SetAvailability

applyDenormSortPrice()

Computes the denormalized listing sort price: the currently valid price (published <= now) for the base user group and default currency at the base quantity tier (from_amount <= 1).

private applyDenormSortPrice(ProductSet $entity) : void

Read from the in-memory owned price collection so it lands in the same insert/update. Left untouched when the save carries no price rows (partial edit); the recalc cron is the authoritative source for every other case (planned prices, other group/currency edges).

Parameters
$entity : ProductSet

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>

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>

columnsInvalidateCache()

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

decimal()

Converts a money value to a `decimal(15,4)` string (columns `price` / `original_price`).

private decimal(mixed $value) : string
Parameters
$value : mixed
Return values
string

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

fetchParameterValueCountsFromEs()

ES branch of {@see fetchParameterValueCounts()}: a `terms` aggregation over the field `pv` of documents whose `productSetId` is among the DB-resolved visible ids.

private fetchParameterValueCountsFromEs(array<int, int> $ids) : array<int, array<string|int, int>>|null

ES is not the source of truth for visibility here, it only counts; the documents mirror the same rel table the SQL aggregation uses (ProductSetDocumentSource::buildParams()).

Parameters
$ids : array<int, int>

visible set ids (non-empty)

Return values
array<int, array<string|int, int>>|null —

parameterId => [value => number of sets]; null = ES gave no valid response, use the SQL fallback

fetchSupplyKindIds()

Returns the supply kind of each set (`cms_system_supply_kinds_supply_kind_id`, not null): raw DBAL, one query regardless of the set count.

private fetchSupplyKindIds(array<string|int, int> $productSetIds) : array<int, int>
Parameters
$productSetIds : array<string|int, int>
Return values
array<int, int> —

keyed by productSetId; sets without a found row (deleted between calls) are absent

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>

normalizeStoredTime()

Normalizes a stored snapshot identity (`published_time` as in the DB = UTC) to `Y-m-d H:i:s`, with no timezone conversion ({@see deletePriceSnapshot()}).

private normalizeStoredTime(string $value) : string
Parameters
$value : string
Return values
string

nowUtc()

Returns "now" as a UTC string for the price time columns.

private nowUtc() : string

The app runs in Europe/Prague while the DB session is UTC (named timezone tables are not available, so SET time_zone does not work). published_time (written through resolvePublishedTime()) is read by the view cms_mod_eshop_product_set_max_published_time_for_prices DB-side (<= current_timestamp(), UTC), so a Prague value 1:1 would light up a new price "from now" with a delay of 1-2 hours. resolveBestPrice() and todayMidnight() compare only with published_time (never DB NOW()), so they must use the same representation.

Return values
string

processImages()

Builds the set gallery from `$meta['images']` (ordered admin form rows, drag&drop order).

private processImages(ProductSet $entity, array<string, mixed> $meta) : Result|null

A row is ['assetId' => int] (existing image) and/or ['image' => ['name','data']] (new upload, created as an asset with variants). The key images absent means the gallery is not handled (the collection stays untouched); an empty array is a legitimate "delete all images".

updateColumn(*, 'assetRels', ...) is a DELETE of all relations of the set plus an INSERT of the sent batch (raw DBAL, same transaction as the save). order is 1-based: ProductSetImageRel::isMain() is order === 1, so the first image is also the main photo of the set (FK asset).

Parameters
$entity : ProductSet
$meta : array<string, mixed>
Return values
Result|null

recalcDenormPrice()

Recalculates and writes the denormalized `price` after a write into the price series (bridge, outside the generic save).

private recalcDenormPrice(int $setId, array<int, object> $prices) : Result

IMPORTANT: returns the write Result; callers must evaluate it. updateColumn() catches DBALException and returns Result::failure() without rethrow, so a discarded value makes the bridge report success while product_sets.price (catalog ordering and listings) stays stale.

Parameters
$setId : int
$prices : array<int, object>
Return values
Result

reject()

Rejects a save from `beforeSave()` and discards the half-built graph from the UnitOfWork.

private reject(ResultCode $code, string $message) : Result

IMPORTANT: hydration into managed entities happens before beforeSave(). A rejected save rolls back the transaction, but the overwritten setItems / translations with scheduled orphanRemoval stay in the UoW; any later flush in the same request (the next save in a pivot loop) would commit them and silently delete the set composition although the user got a validation error. clear() detaches the whole identity map, so nothing of the rejected save remains. Callers do not use the entity after a failure anyway.

Parameters
$code : ResultCode
$message : string
Return values
Result

resolveBestPrice()

Picks the currently valid price: the highest base tier wins, on a tie the most recently published price. Shared by {@see applyDenormSortPrice()} (graph save hot path) and the snapshot bridge ({@see recalcDenormPrice()}), which need the same resolution.

private resolveBestPrice(iterable<string|int, object> $prices, int $groupId, int $currencyId, string $now) : string|null

Takes object instead of the generic BaseProductSetPrice: callers pass an unbound-generic Doctrine Collection (entity graph) or a plain array of Api leaf entities (bridge); the instanceof guard narrows both the same way.

Parameters
$prices : iterable<string|int, object>
$groupId : int
$currencyId : int
$now : string
Return values
string|null

resolveFacetLanguageAlias()

Returns the language alias for choosing the per-language index; null means no usable alias and the caller falls back to the SQL aggregation.

private resolveFacetLanguageAlias() : string|null

Reads the inherited $this->languageContext (LanguageTrait; an own constructor property would collide with the trait), defensively: the context may hold a lazy SingleQuery proxy (call ->get() before instanceof), and in a CLI/Api context there may be no entity at all, then getLang() is used (alias, or id mapped to alias through LanguageManager).

Return values
string|null

resolveImageAsset()

Returns the image id of a row: a new upload is stored as an asset (with variants), for an existing image its id is returned. Null means a row without a file and without an existing image (skip it).

private resolveImageAsset(int $setId, array<string|int, mixed> $row, array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}> $variantConfigs) : int|null
Parameters
$setId : int
$row : array<string|int, mixed>
$variantConfigs : array<string, array{maxWidth: int, maxHeight: int, resizeFlags: int[], useWatermark: bool}>
Return values
int|null

resolvePublishedTime()

Converts a `datetime-local` / any parsable string to `Y-m-d H:i:s` (ORM string in a PK); empty means now.

private resolvePublishedTime(string|null $value) : string

Input without an explicit zone is interpreted as Europe/Prague (the admin form is a Prague wall clock); the result is stored in UTC to match the DB current_timestamp() (view) and the $at path of the resolvers.

Parameters
$value : string|null
Return values
string

resolveSellerId()

Returns the e-shop seller from the settings chain `4/invoicer.sellerId` to `7/invoice.default.sellerId` (the same chain as checkout, {@see OrderCheckoutManager}).

private resolveSellerId() : int|null

A missing or nonexistent seller gives null (callers never claim "incl. VAT" blindly).

Return values
int|null

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

snapshotOutcome()

Wraps the domain outcome of a price operation as a {@see Result} (`data['code']` is the result code).

private snapshotOutcome(bool $success, string $code) : Result
Parameters
$success : bool
$code : string
Return values
Result

todayMidnight()

Returns today's midnight (Prague calendar day, the immutability boundary of the history) as a UTC string, so it compares lexicographically with `$targetTime` / `published_time` (both UTC, see {@see nowUtc()}).

private todayMidnight() : string
Return values
string

usesAssetPathTrait()

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

validateComposition()

Validates the composition invariants of the set:

private validateComposition(ProductSet $entity, int $typeId) : Result|null
  • a product (type = 1) is the selling card of exactly one physical variant: it must have no items (setItems) and its variant must be free (one variant may be sold by one set only). The DB backstop is UNIQUE(product_variant_id); this guard turns it into a readable message instead of a 500.
  • a bundle (type = 2) is composed of other sets: it needs at least one item, must not be bound to a variant (the physical state of a bundle is the state of its leaves) and must not form a cycle (A contains B contains A) or reference itself.

The cycle check goes through SetCompositionService, which uses raw DBAL: find() there would detach managed entities, because the generic save runs clearDoctrine.

Parameters
$entity : ProductSet
$typeId : int
Return values
Result|null
On this page

Search results