ImportProductWriter
in package
Writes one `<product>` card with its variants into the catalog.
Writes only through the existing Api managers (hooks, validation and search-index suppression stay
in effect), never raw SQL. The writer does not judge rules: it applies a ready
ImportProductVerdict. The validator already logged soft errors, so an unknown category or
unit is skipped silently and only importIncomplete is set; the writer logs just what fails
during the write.
Create order: card (activated=0), variants, sets (activated=0), deferred sections, then
activation last. A failure midway marks all items of the card as errors and runs the compensating
cleanup rollbackCreated() (sets, variants, card deleted through the managers). Without it an
inactive card with an orphan variant would stay and a corrected file would never activate it. If
the cleanup fails too, the leftovers are logged for manual deletion. A new variant under an
existing card (createItemUnderCard()) is rolled back the same way. On update a partial run
is possible and is logged as an error.
IMPORTANT: applyParameters() and applyTags() mutate the collections of the loaded or
fresh set in place, before the single ProductSetManager::save(). Never exchange the whole
collection (a reused instance would delete its row), and load the set with tags/parameterRels
includes on update, because the save replaces the object graph and a missing include would null
the existing rows.
Accessories are a second pass: applyAccessories() only queues the request (it may reference a code created later in the file); resolveAccessories() writes it after all cards.
IMPORTANT: prices and stock (applyDeferredSections()) run after the set is saved, outside its
graph. StockDocumentManager::adjust() clears the identity map after commit, so no loaded entity may
be held there: the writer works with ids and raw DBAL reads. A soft error found at that point is
stored with updateColumn('importIncomplete', true), not into the saved graph.
Images are deduplicated through Asset.meta.importSource (see resolveImageBySrc()); a new
download goes through the base64 meta['image'] channel, so the writer never creates an Asset
itself and finds the new id after the save.
A flush failure closes the EntityManager for the rest of the process and a rejected save leaves replaced collections in the UoW, so after every failure recoverEntityManager() clears the UoW and reopens the manager.
Table of Contents
Properties
- $assetManager : AssetManager
- $em : EntityManagerInterface
- $imageDownloader : ImportImageDownloader
- $parameterManager : ParameterManager
- $producerManager : ProducerManager
- $productCategoryManager : ProductCategoryManager
- $productManager : ProductManager
- $productSetManager : ProductSetManager
- $productVariantManager : ProductVariantManager
- $stockDocumentManager : StockDocumentManager
- $stockItemManager : StockItemManager
- $unitOfWorkReset : UnitOfWorkReset
Methods
- __construct() : mixed
- applyImages() : bool
- Writes the set gallery through `meta['images']` and {@see ProductSetManager::processImages()} (DELETE and INSERT; `assetRels` is always a skip-field of the graph, so this channel touches nothing else on the set). Semantics:
- applyPrices() : bool
- Writes a new point of the price series through {@see ProductSetManager::savePriceSnapshot()} at run time (`publishedTime = null` means "now"; the immutable guard does not allow the past).
- applySetTexts() : void
- Applies the set texts per language: name (not null), longName (nullable), shortDescription and description (HTML, sanitized).
- applyTags() : bool
- Applies the set tags (many-to-many, match-only on `ProductTagText.name`). The validator already logged an unknown tag as a soft error, so it is skipped silently. Mutates `$set->getTags()` in place, the same way as {@see applyCategories()} on the card. Always returns `false`: tags are not created, so no error can be found only during the write.
- markSetIncomplete() : bool
- Sets `importIncomplete = 1` with a dedicated `updateColumn` on an already saved set (second pass, sections after the save). The Result is checked: a failure is logged as an error (otherwise the set would look complete) and the caller decides about the summary. An exception is not propagated (the marker is best-effort diagnostics, not a catalog write); the EM is recovered after it.
- resolveAccessories() : void
- Second pass for accessories, called by {@see ImportExecutor} after all cards are processed (after `ImportValidator::finish()`, before the run ends).
- write() : void
- Writes one card according to the verdict and records counts and `touchedSetIds` in `$context->summary`.
- activateSet() : void
- Final set activation: `updateColumn` bypasses hooks (the search index is suppressed anyway, the reindex runs after the import).
- applyAccessories() : void
- Only registers the request in the queue (second pass); the write is in {@see resolveAccessories()}.
- applyCategories() : bool
- Applies the card categories: `href` / `path`, merge (default) | replace, `remove="1"`.
- applyDeferredSections() : void
- Applies the variant sections that run after the set is saved and before activation (on create).
- applyParameters() : bool
- Applies the set parameters with per-key granularity: a mentioned `key` replaces all of its rows (multiselect/checkboxlist = N rows), unmentioned keys stay unchanged, `remove="1"` deletes all rows of the key, and `collectionMode=replace` on `<parameters>` makes the collection exactly match the file (unmentioned keys disappear). An unknown parameter or option (without `autoCreateOptions`) was logged by the validator as a soft error and is skipped silently here.
- applyProductFields() : bool
- Applies the scalar fields of the card: producer (auto-create per settings), type (match-only) and `activated` (update only; on create the activation comes last).
- applyProductTexts() : void
- Applies the card texts per language. On create without `<name>` in the default language the name is taken from the first created variant (single-variant goods are usually named by the variant only).
- applySetFields() : bool
- Applies the scalar fields of the set. Returns `true` for a soft error found only now (currently none - unknown code lists were logged by the validator and are just skipped here).
- applyStock() : bool
- Writes the stock through the movement journal, the only write channel: {@see StockDocumentManager::adjust()} (stocktake document) brings the level to the target quantity.
- assetExists() : bool
- buildAccessory() : ProductSetAccessory
- buildParameterRelRow() : ProductSetParameterRel|null
- Builds one new `ProductSetParameterRel` row by parameter type; `null` means the value could not be assigned.
- buildUniqueCategoryHref() : string
- buildUniqueOptionKey() : string
- Derived `key` of a new option (`<parentKey>-<slug(name)>`), unique across `cms_mod_eshop_parameters` (length 45).
- categoryReference() : ProductCategory
- Lazy FK proxy of a category without a query; `null` here would mean a mapping error, not data.
- collectNamedLanguageIds() : array<int, true>
- Returns the languages in which the file gives a non-empty `<name>`; only there may a new translation row be created.
- createCard() : void
- createCategoryPath() : int|null
- Creates the missing segments of a path like `Elektro / Apple` (parent first), each with only a name and `href` (webalized, unique in the default language), without SEO texts. Created segments are registered in the run code list so later cards of the file find them.
- createItemUnderCard() : bool
- Creates a new variant and set under an existing card (the code is not in the DB, other card codes are).
- createParameterOption() : int|null
- Creates an unknown option as a child `Parameter` row (level 2) for `autoCreateOptions=1`, only through {@see ParameterManager} (`OptionsSyncTrait`, no raw SQL for writes).
- createVariantWithSet() : array{int, int, bool}
- Creates a variant and its sales set (type=1, `activated=0`) under the given card.
- findAssetIdByUrl() : int|null
- Finds an asset across runs by `sha256(url)` stored in `Asset.meta.importSource.urlHash` (not the raw URL): a hex string has no `%`, `_` or backslash, so no LIKE escaping is needed, unlike searching a JSON-escaped URL directly.
- findOrCreateTranslation() : T|null
- Returns the existing translation of a language, otherwise a new one from `$factory`; `$factory === null` means the row is not created (missing name) and `null` is returned.
- findProductAssetId() : int|null
- Returns the current FK of the card photo (raw DBAL; fresh after a `save()` with `meta['image']`).
- findSetVariantId() : int|null
- Returns the variant of the sales set (raw DBAL: the id is enough, hydration would only fill the identity map).
- findSetVatId() : int|null
- Static VAT rate of the set; read only after the set is saved, so a `<vat>` from the same file already applies.
- findStockLevelQuantity() : numeric-string
- Returns the physical stock of the card including reservations through {@see StockItemManager::getAvailabilityBatch()} (the only Store read API for other modules); a warehouse without a `StockLevel` row counts as 0.
- findTextInDefaultLang() : ImportValue|null
- finishGallerySlots() : bool
- Matches the gallery slots with assets after the save (positional zip). Returns `true` for a soft error (unmatched upload, alt not written).
- finishProductImage() : bool
- Finishes after a successful `productManager->save($entity, $plan['meta'])`, when `$productId` already exists: a dedup hit is written with `updateColumn('asset', ...)`, a fresh upload is looked up ({@see findProductAssetId()}) and tagged with `Asset.meta.importSource` for the next dedup.
- hasCardData() : bool
- Whether the file has anything to update on the card (otherwise the card is neither loaded nor saved).
- hasTextInDefaultLang() : bool
- loadAssetTextRows() : array<int, array{name: ?string, description: ?string}>
- loadCurrentPrices() : array<string, array{price: string, originalPrice: string, points: int}>
- Returns the currently valid price per axis from the read-only view `cms_mod_eshop_product_set_current_prices`: the same source of truth the front end and the export ({@see \App\UI\Api\Eshop\Models\Generators\ProductXmlExporter}) read, so "same price means no new snapshot" holds exactly for what the export wrote into the file.
- loadExistingImageAssetIds() : array<int, int>
- Returns the existing gallery of the set ordered by `order` (`order=1` is the main image).
- markAllSetsIncomplete() : void
- Marks all sales sets of the card as incomplete after a card-level soft error. The ids are read with raw DBAL (own module table, no `find()`, so no `clearDoctrine()`), the write goes through the manager.
- markAssetImportSource() : void
- Tags a freshly downloaded asset with its source (the next run finds it via {@see findAssetIdByUrl()}); merges into the existing `meta` (variant dimensions).
- newParameterRel() : ProductSetParameterRel
- preResolveParameterOptions() : bool
- Pre-pass for `autoCreateOptions=1`: creates all unknown parameter options before anything from `<parameters>` is written into `ProductSetParameterRel`.
- recoverEntityManager() : void
- After a failed save/flush: drops the poisoned UoW and reopens the closed EM.
- rejectedUploadReasons() : array<int, string>
- Returns the reasons from `saveInfo.rejectedUploads` of a successful save result ({@see \App\Core\Traits\Api\Models\Managers\SaveTrait::finishRejectedUploads()}): one entry per rejected upload (`UploadRejection::REASON_*`, unknown shape = `?`).
- resolveAccessoryTarget() : int|null
- Maps a code to a set id: first sets touched by this run (including those created later in the file), then the DB.
- resolveActivated() : bool
- Resolves `<activated>` from the value (default 1).
- resolveCategoryId() : int|null
- resolveImageBySrc() : array{0: int, 1: null}|array{0: null, 1: array{name: string, data: string, url: string, sha256: string}}|null
- Resolves `<image src>` to an existing assetId (dedup, no download) or freshly downloaded data for upload. Dedup order: this run ({@see ImportExecutionSummary::$urlToAssetId}), then `Asset.meta.importSource` from earlier runs ({@see findAssetIdByUrl()}), then the download ({@see ImportImageDownloader}). Shared by the gallery and the card photo (same downloader).
- resolveNetAmount() : string|null
- Returns the price without VAT from the `amount`/`amountWithVat` pair; `null` means neither tag is in the file (leave untouched). An empty or non-numeric tag never gets here: the validator blocks it as a hard item error, because a price of "0 by mistake" would sell for free.
- resolveProducerId() : int|null
- Finds a producer by name; an unknown one is created under `autoCreateProducers` (`name` only) and registered in the run code list.
-
resolveProductImageMeta()
: array{meta: array
, incomplete: bool, dedupAssetId: ?int, pendingUrl: ?string, pendingSha256: ?string} - Builds the meta for the nearest `productManager->save()` (create and update call the same save and just pass this along) and decides what to do after the save ({@see finishProductImage()}).
- resolveStockItemCard() : int|null
- Returns the stock item card of the variant: an existing link (raw DBAL, own table), otherwise an idempotent creation via {@see ProductVariantManager::foundStockItemCard()} (card code = set code).
- rollbackCreated() : string
- Compensating cleanup after a failed create: sets, variants and card are deleted through the managers (delete hooks run; search index suppressed).
- rowInt() : int
- DBAL returns `mixed`: guard against a mixed cast (see `StockReader::toInt()`).
- rowString() : string
- tagReference() : ProductTag
- Lazy FK proxy of a tag without a query, same pattern as {@see categoryReference()}.
- tryDelete() : bool
- updateCard() : void
- updateItem() : bool
- Updates an existing set: loads it with texts, applies changes, saves.
- writeAccessoryCollection() : bool
- Writes the `accessories` of one set (merge/replace) with its own `find()` and `save()` using `includes: ['accessories']`. Mutates the collection in place (`add()`/`removeElement()`).
- writeImageAlts() : bool
- Writes alt texts (`<alt>` to `AssetText.name`). {@see AssetManager::updateColumn()} on `translations` is a DELETE and INSERT of all languages of the asset, so the existing rows are loaded first ({@see loadAssetTextRows()}) and only the languages `<alt>` mentions are overwritten; another language (or a `description` created outside the import) stays untouched.
Properties
$assetManager read-only
private
AssetManager
$assetManager
$em read-only
private
EntityManagerInterface
$em
$imageDownloader read-only
private
ImportImageDownloader
$imageDownloader
$parameterManager read-only
private
ParameterManager
$parameterManager
$producerManager read-only
private
ProducerManager
$producerManager
$productCategoryManager read-only
private
ProductCategoryManager
$productCategoryManager
$productManager read-only
private
ProductManager
$productManager
$productSetManager read-only
private
ProductSetManager
$productSetManager
$productVariantManager read-only
private
ProductVariantManager
$productVariantManager
$stockDocumentManager read-only
private
StockDocumentManager
$stockDocumentManager
$stockItemManager read-only
private
StockItemManager
$stockItemManager
$unitOfWorkReset read-only
private
UnitOfWorkReset
$unitOfWorkReset
Methods
__construct()
public
__construct(ProductManager $productManager, ProductVariantManager $productVariantManager, ProductSetManager $productSetManager, ProducerManager $producerManager, ProductCategoryManager $productCategoryManager, ParameterManager $parameterManager, StockDocumentManager $stockDocumentManager, StockItemManager $stockItemManager, ImportImageDownloader $imageDownloader, AssetManager $assetManager, EntityManagerInterface $em, UnitOfWorkReset $unitOfWorkReset) : mixed
Parameters
- $productManager : ProductManager
- $productVariantManager : ProductVariantManager
- $productSetManager : ProductSetManager
- $producerManager : ProducerManager
- $productCategoryManager : ProductCategoryManager
- $parameterManager : ParameterManager
- $stockDocumentManager : StockDocumentManager
- $stockItemManager : StockItemManager
- $imageDownloader : ImportImageDownloader
- $assetManager : AssetManager
- $em : EntityManagerInterface
- $unitOfWorkReset : UnitOfWorkReset
applyImages()
Writes the set gallery through `meta['images']` and {@see ProductSetManager::processImages()} (DELETE and INSERT; `assetRels` is always a skip-field of the graph, so this channel touches nothing else on the set). Semantics:
public
applyImages(ImportCollectionDto<string|int, ImportImageDto>|null $images, int $setId, string|null $code, string $action, ImportExecutionContext $context) : bool
merge(default) sits on the existing gallery (loadExistingImageAssetIds(), ordered byorder) plus new or updated items;replacematches the file exactly<image assetId>is an existing asset (verified by assetExists(), otherwise a soft error)<image src>goes through resolveImageBySrc(): a dedup hit (this run orAsset.metafrom earlier runs) returns the existing id without downloading; otherwise a download plus a base64 row forprocessImages()(the asset is created insideafterSave()and its id is found only after the save, by positional zip with$slots)remove="1"removes only the link (the asset is not deleted, another set may share it)main="1"moves its own item toorder=1(the lastmainin the file wins)- a merged gallery above ImportRules::MAX_IMAGES_PER_VARIANT is truncated (the surplus is not attached, plus a soft error); the validator checks only the number of file items, not the merge result
- alt texts (
<alt>) are written only to items the file mentions; writeImageAlts() merges them with the existing ones (another language stays untouched)
Takes ?ImportCollectionDto $images, $code and $action directly (not ImportVariantDto/
ImportItemVerdict) so it can also serve bundle sets without a variant
(ImportBundleWriter).
Parameters
- $images : ImportCollectionDto<string|int, ImportImageDto>|null
- $setId : int
- $code : string|null
- $action : string
- $context : ImportExecutionContext
Return values
boolapplyPrices()
Writes a new point of the price series through {@see ProductSetManager::savePriceSnapshot()} at run time (`publishedTime = null` means "now"; the immutable guard does not allow the past).
public
applyPrices(array<int, ImportPriceDto> $prices, int $setId, string|null $code, string $action, ImportExecutionContext $context) : bool
Rules:
- the axis is
currencyxfromAmount(default 0 = base band,1is its alias) xuserGroupId(default = base group); axes the file does not mention are untouched, because the view computing the maximum published time works per axis and an older point of another currency or band stays valid <amount>is the price without VAT 1:1;<amountWithVat>is converted with the set's current rate (ImportRules::priceWithoutVat()); the rate is read only now, so a<vat>from the same file (written inapplySetFields()before the save) already applies- an unmentioned
<originalAmount(WithVat)>/<points>is untouched: it is taken over from the currently valid point of the axis, not zeroed (otherwise a weekly price update would delete the promotional original price) - an equal value on the axis means the row does not go into the snapshot; when no axis differs,
savePriceSnapshot()is not called at all, so an export-import roundtrip does not extend the series
The validator caught hard errors (both tags of a pair, non-numeric or negative amount, bad
fromAmount) and logged an unknown currency as soft; the writer just skips it.
Takes list<ImportPriceDto> $prices, $code and $action directly (not ImportVariantDto/
ImportItemVerdict) so it can also serve bundle sets without a variant
(ImportBundleWriter).
Parameters
- $prices : array<int, ImportPriceDto>
- $setId : int
- $code : string|null
- $action : string
- $context : ImportExecutionContext
Return values
boolapplySetTexts()
Applies the set texts per language: name (not null), longName (nullable), shortDescription and description (HTML, sanitized).
public
applySetTexts(ProductSet $set, array<string, array<int, ImportValue>> $texts, ImportExecutionContext $context) : void
IMPORTANT: a language row that does not exist yet is created only with a non-empty <name lang>,
because an empty name would override the language fallback on the front end.
Takes $texts directly (not ImportVariantDto) so it can also serve bundle sets without a
variant (ImportBundleWriter).
Parameters
- $set : ProductSet
- $texts : array<string, array<int, ImportValue>>
- $context : ImportExecutionContext
applyTags()
Applies the set tags (many-to-many, match-only on `ProductTagText.name`). The validator already logged an unknown tag as a soft error, so it is skipped silently. Mutates `$set->getTags()` in place, the same way as {@see applyCategories()} on the card. Always returns `false`: tags are not created, so no error can be found only during the write.
public
applyTags(ProductSet $set, ImportCollectionDto<string|int, ImportTagDto>|null $tags, ImportExecutionContext $context) : bool
Takes ?ImportCollectionDto $tags (not ImportVariantDto) so it can also serve bundle sets
without a variant (ImportBundleWriter).
Parameters
- $set : ProductSet
- $tags : ImportCollectionDto<string|int, ImportTagDto>|null
- $context : ImportExecutionContext
Return values
boolmarkSetIncomplete()
Sets `importIncomplete = 1` with a dedicated `updateColumn` on an already saved set (second pass, sections after the save). The Result is checked: a failure is logged as an error (otherwise the set would look complete) and the caller decides about the summary. An exception is not propagated (the marker is best-effort diagnostics, not a catalog write); the EM is recovered after it.
public
markSetIncomplete(int $setId, int $line, string|null $code, string|null $action, ImportExecutionContext $context) : bool
Parameters
- $setId : int
- $line : int
- $code : string|null
- $action : string|null
- $context : ImportExecutionContext
Return values
boolresolveAccessories()
Second pass for accessories, called by {@see ImportExecutor} after all cards are processed (after `ImportValidator::finish()`, before the run ends).
public
resolveAccessories(ImportExecutionContext $context) : void
A code is resolved first against sets created or updated in this run
(ImportExecutionSummary::$codeToSetId), then against the DB (existing sets outside the
file). An unknown code is a soft error (log plus importIncomplete on the source set); a set
cannot be its own accessory.
Parameters
- $context : ImportExecutionContext
write()
Writes one card according to the verdict and records counts and `touchedSetIds` in `$context->summary`.
public
write(ImportProductDto $product, ImportProductVerdict $verdict, ImportExecutionContext $context) : void
After the return the caller (executor) raises processedItems by the number of card items.
Parameters
- $product : ImportProductDto
- $verdict : ImportProductVerdict
- $context : ImportExecutionContext
activateSet()
Final set activation: `updateColumn` bypasses hooks (the search index is suppressed anyway, the reindex runs after the import).
private
activateSet(int $setId, bool $activated) : void
Parameters
- $setId : int
- $activated : bool
applyAccessories()
Only registers the request in the queue (second pass); the write is in {@see resolveAccessories()}.
private
applyAccessories(ImportVariantDto $variant, int $setId, ImportItemVerdict $item, ImportExecutionContext $context) : void
Parameters
- $variant : ImportVariantDto
- $setId : int
- $item : ImportItemVerdict
- $context : ImportExecutionContext
applyCategories()
Applies the card categories: `href` / `path`, merge (default) | replace, `remove="1"`.
private
applyCategories(Product $entity, ImportProductDto $product, bool $isInsert, ImportExecutionContext $context) : bool
An unknown category is skipped (validator already warned) or, under autoCreateCategories,
created by path. Returns true for a soft error found only now (auto-create failed).
Parameters
- $entity : Product
- $product : ImportProductDto
- $isInsert : bool
- $context : ImportExecutionContext
Return values
boolapplyDeferredSections()
Applies the variant sections that run after the set is saved and before activation (on create).
private
applyDeferredSections(ImportVariantDto $variant, int $setId, ImportItemVerdict $item, ImportExecutionContext $context) : void
Accessories may reference a code created later in the same file, so they are only registered in
$context->summary->accessoryQueue; resolveAccessories() does the actual write after all
cards are done. Prices, stock and images are written immediately (they only need the set or
variant id).
Parameters
- $variant : ImportVariantDto
- $setId : int
- $item : ImportItemVerdict
- $context : ImportExecutionContext
applyParameters()
Applies the set parameters with per-key granularity: a mentioned `key` replaces all of its rows (multiselect/checkboxlist = N rows), unmentioned keys stay unchanged, `remove="1"` deletes all rows of the key, and `collectionMode=replace` on `<parameters>` makes the collection exactly match the file (unmentioned keys disappear). An unknown parameter or option (without `autoCreateOptions`) was logged by the validator as a soft error and is skipped silently here.
private
applyParameters(ProductSet $set, ImportVariantDto $variant, ImportExecutionContext $context) : bool
Mutates $set->getParameterRels() in place (add()/removeElement()).
IMPORTANT: never exchange the whole collection or reuse an existing instance in a new one -
the reused instance would delete its row.
Returns true when autoCreateOptions=1 and creating an option failed during the write (an
error the validator could not know).
Parameters
- $set : ProductSet
- $variant : ImportVariantDto
- $context : ImportExecutionContext
Return values
boolapplyProductFields()
Applies the scalar fields of the card: producer (auto-create per settings), type (match-only) and `activated` (update only; on create the activation comes last).
private
applyProductFields(Product $entity, ImportProductDto $product, ImportExecutionContext $context) : bool
Returns true for a soft error found only now.
Parameters
- $entity : Product
- $product : ImportProductDto
- $context : ImportExecutionContext
Return values
boolapplyProductTexts()
Applies the card texts per language. On create without `<name>` in the default language the name is taken from the first created variant (single-variant goods are usually named by the variant only).
private
applyProductTexts(Product $entity, ImportProductDto $product, ImportVariantDto|null $nameFallback, ImportExecutionContext $context) : void
A new translation row for a language is created only with a non-empty name (see applySetTexts()).
Parameters
- $entity : Product
- $product : ImportProductDto
- $nameFallback : ImportVariantDto|null
- $context : ImportExecutionContext
applySetFields()
Applies the scalar fields of the set. Returns `true` for a soft error found only now (currently none - unknown code lists were logged by the validator and are just skipped here).
private
applySetFields(ProductSet $set, ImportVariantDto $variant, bool $isInsert, ImportExecutionContext $context) : bool
Parameters
- $set : ProductSet
- $variant : ImportVariantDto
- $isInsert : bool
- $context : ImportExecutionContext
Return values
boolapplyStock()
Writes the stock through the movement journal, the only write channel: {@see StockDocumentManager::adjust()} (stocktake document) brings the level to the target quantity.
private
applyStock(ImportVariantDto $variant, int $setId, ImportItemVerdict $item, ImportExecutionContext $context) : bool
IMPORTANT: StockLevel is never written directly (it is only a denormalization).
- the warehouse is
Warehouse.code; without the attribute the default is the only active one; the validator logs an unknown or ambiguous warehouse as a soft error and the section is skipped - the stock item card is created idempotently via ProductVariantManager::foundStockItemCard()
(card code = set code, link
meta['stockItemId']to the variant) mode="set"is the target physical level including reservations (StockLevel.quantity), so the movement is the difference; the same number again is a zero difference and no movement (idempotent)mode="add"moves by plus or minus the value- a negative resulting level is a hard item error: nothing is written to stock (
adjust()is not even called) and the set getsimportIncomplete. A dry run cannot detect it (depends on live state)
Parameters
- $variant : ImportVariantDto
- $setId : int
- $item : ImportItemVerdict
- $context : ImportExecutionContext
Return values
boolassetExists()
private
assetExists(int $assetId) : bool
Parameters
- $assetId : int
Return values
boolbuildAccessory()
private
buildAccessory(ProductSet $set, int $accessorySetId, int $order) : ProductSetAccessory
Parameters
- $set : ProductSet
- $accessorySetId : int
- $order : int
Return values
ProductSetAccessorybuildParameterRelRow()
Builds one new `ProductSetParameterRel` row by parameter type; `null` means the value could not be assigned.
private
buildParameterRelRow(ProductSet $set, int $parameterId, array{id: int, type: int|null, hasOptions: bool} $meta, ImportParameterDto $dto, ImportExecutionContext $context) : ProductSetParameterRel|null
Parameters
- $set : ProductSet
- $parameterId : int
- $meta : array{id: int, type: int|null, hasOptions: bool}
- $dto : ImportParameterDto
- $context : ImportExecutionContext
Return values
ProductSetParameterRel|nullbuildUniqueCategoryHref()
private
buildUniqueCategoryHref(string $name, ImportCodebooks $codebooks) : string
Parameters
- $name : string
- $codebooks : ImportCodebooks
Return values
stringbuildUniqueOptionKey()
Derived `key` of a new option (`<parentKey>-<slug(name)>`), unique across `cms_mod_eshop_parameters` (length 45).
private
buildUniqueOptionKey(string $parentKey, string $name) : string
Parameters
- $parentKey : string
- $name : string
Return values
stringcategoryReference()
Lazy FK proxy of a category without a query; `null` here would mean a mapping error, not data.
private
categoryReference(int $id) : ProductCategory
Parameters
- $id : int
Return values
ProductCategorycollectNamedLanguageIds()
Returns the languages in which the file gives a non-empty `<name>`; only there may a new translation row be created.
private
collectNamedLanguageIds(array<string, array<int, ImportValue>> $texts, ImportCodebooks $codebooks) : array<int, true>
Parameters
- $texts : array<string, array<int, ImportValue>>
- $codebooks : ImportCodebooks
Return values
array<int, true>createCard()
private
createCard(ImportProductDto $product, ImportProductVerdict $verdict, ImportExecutionContext $context) : void
Parameters
- $product : ImportProductDto
- $verdict : ImportProductVerdict
- $context : ImportExecutionContext
createCategoryPath()
Creates the missing segments of a path like `Elektro / Apple` (parent first), each with only a name and `href` (webalized, unique in the default language), without SEO texts. Created segments are registered in the run code list so later cards of the file find them.
private
createCategoryPath(string $path, int $line, ImportExecutionContext $context) : int|null
Parameters
- $path : string
- $line : int
- $context : ImportExecutionContext
Return values
int|nullcreateItemUnderCard()
Creates a new variant and set under an existing card (the code is not in the DB, other card codes are).
private
createItemUnderCard(int $productId, ImportVariantDto $variant, ImportItemVerdict $item, bool $cardIncomplete, ImportExecutionContext $context) : bool
Parameters
- $productId : int
- $variant : ImportVariantDto
- $item : ImportItemVerdict
- $cardIncomplete : bool
- $context : ImportExecutionContext
Return values
boolcreateParameterOption()
Creates an unknown option as a child `Parameter` row (level 2) for `autoCreateOptions=1`, only through {@see ParameterManager} (`OptionsSyncTrait`, no raw SQL for writes).
private
createParameterOption(int $parentId, string $name, ImportExecutionContext $context[, string|null &$keyError = null ]) : int|null
IMPORTANT: the sync is a full replace of all options of the parent, so the meta must also carry
the existing options unchanged, otherwise creating one option would delete all the others.
The new key is derived (<parentKey>-<webalize(name)>, unique) and written by a separate
updateColumn(), because the options sync ignores key. Returns null when creation fails;
when only the key write fails (Result or exception) it returns the id and puts the reason into
$keyError (the option exists).
Parameters
- $parentId : int
- $name : string
- $context : ImportExecutionContext
- $keyError : string|null = null
Return values
int|nullcreateVariantWithSet()
Creates a variant and its sales set (type=1, `activated=0`) under the given card.
private
createVariantWithSet(int $productId, ImportVariantDto $variant, ImportItemVerdict $item, bool $cardIncomplete, ImportExecutionContext $context, array{variantIds: list, setIds: list} &$created) : array{int, int, bool}
Returns [variantId, setId, activateLast]. On failure it throws and the caller decides; the
ids already created are in $created, so the caller can roll them back
(rollbackCreated()).
Parameters
- $productId : int
- $variant : ImportVariantDto
- $item : ImportItemVerdict
- $cardIncomplete : bool
- $context : ImportExecutionContext
-
$created
: array{variantIds: list
, setIds: list }
Return values
array{int, int, bool}findAssetIdByUrl()
Finds an asset across runs by `sha256(url)` stored in `Asset.meta.importSource.urlHash` (not the raw URL): a hex string has no `%`, `_` or backslash, so no LIKE escaping is needed, unlike searching a JSON-escaped URL directly.
private
findAssetIdByUrl(string|null $url) : int|null
Parameters
- $url : string|null
Return values
int|nullfindOrCreateTranslation()
Returns the existing translation of a language, otherwise a new one from `$factory`; `$factory === null` means the row is not created (missing name) and `null` is returned.
private
findOrCreateTranslation(Collection<int, T> $translations, int $languageId, callable(): T|null $factory) : T|null
Parameters
- $translations : Collection<int, T>
- $languageId : int
- $factory : callable(): T|null
Tags
Return values
T|nullfindProductAssetId()
Returns the current FK of the card photo (raw DBAL; fresh after a `save()` with `meta['image']`).
private
findProductAssetId(int $productId) : int|null
Parameters
- $productId : int
Return values
int|nullfindSetVariantId()
Returns the variant of the sales set (raw DBAL: the id is enough, hydration would only fill the identity map).
private
findSetVariantId(int $setId) : int|null
Parameters
- $setId : int
Return values
int|nullfindSetVatId()
Static VAT rate of the set; read only after the set is saved, so a `<vat>` from the same file already applies.
private
findSetVatId(int $setId) : int|null
Parameters
- $setId : int
Return values
int|nullfindStockLevelQuantity()
Returns the physical stock of the card including reservations through {@see StockItemManager::getAvailabilityBatch()} (the only Store read API for other modules); a warehouse without a `StockLevel` row counts as 0.
private
findStockLevelQuantity(int $stockItemId, int $warehouseId) : numeric-string
Parameters
- $stockItemId : int
- $warehouseId : int
Return values
numeric-stringfindTextInDefaultLang()
private
findTextInDefaultLang(array<int, ImportValue> $values, ImportExecutionContext $context) : ImportValue|null
Parameters
- $values : array<int, ImportValue>
- $context : ImportExecutionContext
Return values
ImportValue|nullfinishGallerySlots()
Matches the gallery slots with assets after the save (positional zip). Returns `true` for a soft error (unmatched upload, alt not written).
private
finishGallerySlots(array<int, ImportImageDto}> $slots, array<int, int> $finalIds, Result $result, int $line, string|null $code, string $action, ImportExecutionContext $context) : bool
Parameters
- $slots : array<int, ImportImageDto}>
- $finalIds : array<int, int>
-
gallery of the set after the save, by
order - $result : Result
- $line : int
- $code : string|null
- $action : string
- $context : ImportExecutionContext
Return values
boolfinishProductImage()
Finishes after a successful `productManager->save($entity, $plan['meta'])`, when `$productId` already exists: a dedup hit is written with `updateColumn('asset', ...)`, a fresh upload is looked up ({@see findProductAssetId()}) and tagged with `Asset.meta.importSource` for the next dedup.
private
finishProductImage(int $productId, array{meta: array, incomplete: bool, dedupAssetId: ?int, pendingUrl: ?string, pendingSha256: ?string} $plan, ImportExecutionContext $context[, Result|null $saveResult = null ][, int|null $previousAssetId = null ]) : bool
IMPORTANT: $saveResult is the successful result of the same save. A rejected upload
(saveInfo.rejectedUploads, UploadRejection) does not fail the save, it only leaves the FK
unchanged. Then the current (old) asset must not be tagged with importSource of the new URL nor
registered in the run cache, otherwise the next product with the same URL would get a foreign old
photo. The same applies when the FK stayed $previousAssetId (or empty) with no reported
rejection. Both are soft errors (warning plus incomplete).
Parameters
- $productId : int
-
$plan
: array{meta: array
, incomplete: bool, dedupAssetId: ?int, pendingUrl: ?string, pendingSha256: ?string} - $context : ImportExecutionContext
- $saveResult : Result|null = null
- $previousAssetId : int|null = null
Return values
boolhasCardData()
Whether the file has anything to update on the card (otherwise the card is neither loaded nor saved).
private
hasCardData(ImportProductDto $product) : bool
Parameters
- $product : ImportProductDto
Return values
boolhasTextInDefaultLang()
private
hasTextInDefaultLang(array<int, ImportValue> $values, ImportExecutionContext $context) : bool
Parameters
- $values : array<int, ImportValue>
- $context : ImportExecutionContext
Return values
boolloadAssetTextRows()
private
loadAssetTextRows(int $assetId) : array<int, array{name: ?string, description: ?string}>
Parameters
- $assetId : int
Return values
array<int, array{name: ?string, description: ?string}> —language id to the existing AssetText
loadCurrentPrices()
Returns the currently valid price per axis from the read-only view `cms_mod_eshop_product_set_current_prices`: the same source of truth the front end and the export ({@see \App\UI\Api\Eshop\Models\Generators\ProductXmlExporter}) read, so "same price means no new snapshot" holds exactly for what the export wrote into the file.
private
loadCurrentPrices(int $setId) : array<string, array{price: string, originalPrice: string, points: int}>
Raw DBAL (own module table, no find(), so no clearDoctrine()).
Parameters
- $setId : int
Return values
array<string, array{price: string, originalPrice: string, points: int}> —fromAmount|userGroupId|currencyId
loadExistingImageAssetIds()
Returns the existing gallery of the set ordered by `order` (`order=1` is the main image).
private
loadExistingImageAssetIds(int $setId) : array<int, int>
Parameters
- $setId : int
Return values
array<int, int>markAllSetsIncomplete()
Marks all sales sets of the card as incomplete after a card-level soft error. The ids are read with raw DBAL (own module table, no `find()`, so no `clearDoctrine()`), the write goes through the manager.
private
markAllSetsIncomplete(int $productId, ImportExecutionContext $context) : void
Parameters
- $productId : int
- $context : ImportExecutionContext
markAssetImportSource()
Tags a freshly downloaded asset with its source (the next run finds it via {@see findAssetIdByUrl()}); merges into the existing `meta` (variant dimensions).
private
markAssetImportSource(int $assetId, string $url, string $sha256) : void
Parameters
- $assetId : int
- $url : string
- $sha256 : string
newParameterRel()
private
newParameterRel(ProductSet $set, int $parameterId) : ProductSetParameterRel
Parameters
- $set : ProductSet
- $parameterId : int
Return values
ProductSetParameterRelpreResolveParameterOptions()
Pre-pass for `autoCreateOptions=1`: creates all unknown parameter options before anything from `<parameters>` is written into `ProductSetParameterRel`.
private
preResolveParameterOptions(ImportVariantDto $variant, ImportItemVerdict $item, ImportExecutionContext $context) : bool
IMPORTANT: must run before find() or creation of the set. createParameterOption() calls
parameterManager->find()/findAll(), whose clearDoctrine() detaches all entities held in the
identity map (including a set being mutated), so the later save() of the set would fail with
"A new entity was found through the relationship ... tags" (loose/detached items in its untouched
collections). buildParameterRelRow() then only reads $codebooks (no DB).
Returns true when at least one creation failed (a soft error found during the write).
Parameters
- $variant : ImportVariantDto
- $item : ImportItemVerdict
- $context : ImportExecutionContext
Return values
boolrecoverEntityManager()
After a failed save/flush: drops the poisoned UoW and reopens the closed EM.
private
recoverEntityManager() : void
IMPORTANT: without resetting the persisters the next flush would replay the failed queue. The reflection used here is vendor-fragile and covered by a harness test.
rejectedUploadReasons()
Returns the reasons from `saveInfo.rejectedUploads` of a successful save result ({@see \App\Core\Traits\Api\Models\Managers\SaveTrait::finishRejectedUploads()}): one entry per rejected upload (`UploadRejection::REASON_*`, unknown shape = `?`).
private
static rejectedUploadReasons(Result $result) : array<int, string>
Parameters
- $result : Result
Return values
array<int, string>resolveAccessoryTarget()
Maps a code to a set id: first sets touched by this run (including those created later in the file), then the DB.
private
resolveAccessoryTarget(string $code, ImportExecutionContext $context) : int|null
Parameters
- $code : string
- $context : ImportExecutionContext
Return values
int|nullresolveActivated()
Resolves `<activated>` from the value (default 1).
private
resolveActivated(ImportValue|null $value) : bool
Parameters
- $value : ImportValue|null
Return values
boolresolveCategoryId()
private
resolveCategoryId(ImportCategoryDto $category, ImportExecutionContext $context, bool &$incomplete) : int|null
Parameters
- $category : ImportCategoryDto
- $context : ImportExecutionContext
- $incomplete : bool
Return values
int|nullresolveImageBySrc()
Resolves `<image src>` to an existing assetId (dedup, no download) or freshly downloaded data for upload. Dedup order: this run ({@see ImportExecutionSummary::$urlToAssetId}), then `Asset.meta.importSource` from earlier runs ({@see findAssetIdByUrl()}), then the download ({@see ImportImageDownloader}). Shared by the gallery and the card photo (same downloader).
private
resolveImageBySrc(string $url, string|null $code, string|null $action, int $line, ImportExecutionContext $context) : array{0: int, 1: null}|array{0: null, 1: array{name: string, data: string, url: string, sha256: string}}|null
Parameters
- $url : string
- $code : string|null
- $action : string|null
- $line : int
- $context : ImportExecutionContext
Return values
array{0: int, 1: null}|array{0: null, 1: array{name: string, data: string, url: string, sha256: string}}|null —null = download/validation failed (already logged as a soft error)
resolveNetAmount()
Returns the price without VAT from the `amount`/`amountWithVat` pair; `null` means neither tag is in the file (leave untouched). An empty or non-numeric tag never gets here: the validator blocks it as a hard item error, because a price of "0 by mistake" would sell for free.
private
resolveNetAmount(ImportValue|null $amount, ImportValue|null $amountWithVat, string|null $vatPercents) : string|null
Parameters
- $amount : ImportValue|null
- $amountWithVat : ImportValue|null
- $vatPercents : string|null
Return values
string|nullresolveProducerId()
Finds a producer by name; an unknown one is created under `autoCreateProducers` (`name` only) and registered in the run code list.
private
resolveProducerId(ImportValue $value, ImportExecutionContext $context) : int|null
Parameters
- $value : ImportValue
- $context : ImportExecutionContext
Return values
int|nullresolveProductImageMeta()
Builds the meta for the nearest `productManager->save()` (create and update call the same save and just pass this along) and decides what to do after the save ({@see finishProductImage()}).
private
resolveProductImageMeta(ImportProductDto $product, string|null $code, string|null $action, ImportExecutionContext $context) : array{meta: array, incomplete: bool, dedupAssetId: ?int, pendingUrl: ?string, pendingSha256: ?string}
meta['image'] (base64) handles only a new upload or deletion; an existing assetId (dedup hit)
must be written by a separate updateColumn('asset', ...), because that channel accepts no id.
Parameters
- $product : ImportProductDto
- $code : string|null
- $action : string|null
- $context : ImportExecutionContext
Return values
array{meta: arrayresolveStockItemCard()
Returns the stock item card of the variant: an existing link (raw DBAL, own table), otherwise an idempotent creation via {@see ProductVariantManager::foundStockItemCard()} (card code = set code).
private
resolveStockItemCard(int $variantId, int $line, ImportItemVerdict $item, ImportExecutionContext $context) : int|null
Parameters
- $variantId : int
- $line : int
- $item : ImportItemVerdict
- $context : ImportExecutionContext
Return values
int|nullrollbackCreated()
Compensating cleanup after a failed create: sets, variants and card are deleted through the managers (delete hooks run; search index suppressed).
private
rollbackCreated(int|null $productId, array<int, int> $variantIds, array<int, int> $setIds, int $line, string|null $code, ImportExecutionContext $context) : string
IMPORTANT: "nothing was active" does not always hold - when only the activation of the Nth
set or of the card fails, earlier sets already have activated=1. A successful cleanup
deletes them anyway, but leftovers of a failed cleanup may be active (the warning says so).
All sets of this item are removed from the run lookup registries
(ImportExecutionSummary::forgetSets(): code to id, accessory queue), leftovers
included, because they are not an accepted item and nothing may be matched to them from
the cache. In touchedSetIds deleted sets are removed (a reindex would target a missing
document) and leftovers are added (they exist in the DB and may be active, so the reindex
must match the DB). Returns a note for the item error line; success logs an info
"item rolled back", failure logs a warning with the leftovers.
Parameters
- $productId : int|null
- $variantIds : array<int, int>
- $setIds : array<int, int>
- $line : int
- $code : string|null
- $context : ImportExecutionContext
Return values
stringrowInt()
DBAL returns `mixed`: guard against a mixed cast (see `StockReader::toInt()`).
private
rowInt(mixed $value) : int
Parameters
- $value : mixed
Return values
introwString()
private
rowString(mixed $value) : string
Parameters
- $value : mixed
Return values
stringtagReference()
Lazy FK proxy of a tag without a query, same pattern as {@see categoryReference()}.
private
tagReference(int $id) : ProductTag
Parameters
- $id : int
Return values
ProductTagtryDelete()
private
tryDelete(callable(): bool $delete) : bool
Parameters
- $delete : callable(): bool
Return values
boolupdateCard()
private
updateCard(ImportProductDto $product, ImportProductVerdict $verdict, int $productId, ImportExecutionContext $context) : void
Parameters
- $product : ImportProductDto
- $verdict : ImportProductVerdict
- $productId : int
- $context : ImportExecutionContext
updateItem()
Updates an existing set: loads it with texts, applies changes, saves.
private
updateItem(ImportVariantDto $variant, ImportItemVerdict $item, bool $cardIncomplete, ImportExecutionContext $context) : bool
Parameters
- $variant : ImportVariantDto
- $item : ImportItemVerdict
- $cardIncomplete : bool
- $context : ImportExecutionContext
Return values
boolwriteAccessoryCollection()
Writes the `accessories` of one set (merge/replace) with its own `find()` and `save()` using `includes: ['accessories']`. Mutates the collection in place (`add()`/`removeElement()`).
private
writeAccessoryCollection(int $setId, string $mode, array<int, true> $addIds, array<int, true> $removeIds, array{code: string|null, action: string, line: int} $entry, ImportExecutionContext $context) : bool
IMPORTANT: never exchange the instance (the reused one would delete its row), and the includes must be complete because the save replaces the object graph.
Returns false when the collection was not written (set not found or save failed); the error is
already logged and resolveAccessories() handles the summary and marker. Exceptions
(find/save) propagate to the caller.
Parameters
- $setId : int
- $mode : string
- $addIds : array<int, true>
- $removeIds : array<int, true>
- $entry : array{code: string|null, action: string, line: int}
- $context : ImportExecutionContext
Return values
boolwriteImageAlts()
Writes alt texts (`<alt>` to `AssetText.name`). {@see AssetManager::updateColumn()} on `translations` is a DELETE and INSERT of all languages of the asset, so the existing rows are loaded first ({@see loadAssetTextRows()}) and only the languages `<alt>` mentions are overwritten; another language (or a `description` created outside the import) stays untouched.
private
writeImageAlts(int $assetId, array<int, ImportValue> $alts, ImportExecutionContext $context) : bool
Returns false when the write failed (warning already logged); the caller propagates it to
importIncomplete of the set.
Parameters
- $assetId : int
- $alts : array<int, ImportValue>
- $context : ImportExecutionContext