CoolCMS API

ComplaintManager extends Manager
in package
uses BlacklistGuardTrait, ComplaintFileTrait, SourceLockTransactionTrait

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

Tags
extends

Table of Contents

Constants

MAX_FILE_SIZE  : int = 5 * 1024 * 1024
MAX_FILES  : int = 5
Attachment limits per complaint.
DELETED_IDS_META  : string = '__deletedIds'
Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.
COMPLAINTS_TABLE  : string = 'cms_mod_eshop_complaints'
CREATED_STATUS_KEY  : string = 'CREATED'
DEFAULT_EXPIRATION_DAYS  : int = 30
Fallback number of days until expiry when `4/complaint.expirationDays` is not set.
DESCRIPTION_MAX_LENGTH  : int = 5000
DESCRIPTION_MIN_LENGTH  : int = 20
Length limits of the defect description, column `text`.
ELIGIBLE_ORDER_STATUS_KEYS  : array<string|int, mixed> = ['SENT', 'DONE', 'RETURNED']
Order status keys from which a complaint may be submitted.
FK_STATUS  : string = 'cms_mod_eshop_complaint_statuses_complaint_sta...
ITEMS_TABLE  : string = 'cms_mod_eshop_complaint_items'
LOCK_MAX_ATTEMPTS  : int = 3
LOCK_SCALE  : int = 3
META_CREATED_EMAIL  : string = '__complaintCreatedEmail'
Internal meta flag meaning "this complaint gets the `CREATED` e-mail"; set only by {@see self::doSubmitForUser()}.
REJECTED_STATUS_KEY  : string = 'REJECTED'
SAVE_LOCK_TIMEOUT  : int = 5
Per-complaint lock timeout; the lock name is the same as in {@see ComplaintTransitionManager} and {@see ComplaintStockManager}.
STATUSES_TABLE  : string = 'cms_mod_eshop_complaint_statuses'
STORE_DOCUMENTS_TABLE  : string = 'cms_mod_store_stock_documents'
STORE_SOURCE_MODULE  : int = 4
Source of the Store return document, see {@see ComplaintStockManager} (`SourceRef(4,'complaint',$id)`).
STORE_SOURCE_TYPE  : string = 'complaint'
SUBMIT_LOCK_TIMEOUT  : int = 5

Properties

$service  : Service<string|int, TEntity>
Manager service; subclasses narrow the type (`@property` in the Api/Admin/Front manager).
$appContext  : AppContext
$assetFormatRegistry  : AssetFormatRegistry|null
Format processor registry for file uploads, used by {@see \App\Core\Traits\Api\Models\Managers\AssetProcessingTrait}.
$blacklistManager  : BlacklistManager|null
$cacheTagInvalidator  : CacheTagInvalidator|null
Post-commit invalidator of the wire-payload query cache (tag bump per entity class).
$defaultSkipFields  : array<int, string>
$internalCallContext  : InternalCallContext|null
Tells whether the call is an internal bridge call; the single source for reading uploaded items with `path` ({@see readUploadedItem()}).
$languageContext  : LanguageContext|null
$nonInvalidatingColumns  : array<string|int, string>
Columns 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
$complaintSolutionManager  : ComplaintSolutionManager
$complaintStatusManager  : ComplaintStatusManager
$em  : EntityManagerInterface
$emailGenerator  : ComplaintEmailGenerator
$emailQueueManager  : EmailQueueManager
$fileManager  : FileManager
$orderManager  : OrderManager
$pendingComplaintDirs  : array<int, string>
Attachment directories created by this save (deleted when they stay empty).
$pendingComplaintFiles  : array<int, string>
Absolute paths of files written during the currently running complaint save.
$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`.

Methods

__construct()  : mixed
canUserWrite()  : bool
Tells whether `$userId` may write this complaint; the single input of the RBAC gate of the `save` action ({@see \App\UI\Api\Eshop\Presenters\ComplaintPresenter::verifyOwnership()}).
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 delete by filters (`api/eshop/complaint/delete-all`); it goes through {@see delete()}.
detailForUser()  : array{success: bool, message?: string, errorCode?: string, complaint?: array}
Returns the detail of the user's own complaint.
find()  : T|null
findAll()  : T> : list>)
findBy()  : T|null
findRelatedIds()  : array<int, int>|null
Returns the ids of target entities that already belong to the record `$parentId` through the relation collection `$property` (for example `('assetRels', 'asset')`, `('fileRels', 'file')`). Reads straight from the database, not through the Api read path (no `UoW::clear()` in the middle of save hooks).
getAppContext()  : AppContext
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 the owner data: the owner is the `$user` association (mapped in the Api leaf to `cms_system_users_user_id`), filled from the JWT on an external insert.
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()}.
getFileData()  : array{success: bool, errorCode?: string, message?: string, path?: string, extension?: string, complaintId?: int, position?: int}
Same for the admin, without the ownership gate (admin sees all complaints); the privilege is checked by {@see \App\UI\Api\Eshop\Presenters\ComplaintPresenter::internalActionFile()}.
getFileDataForUser()  : array{success: bool, errorCode?: string, message?: string, path?: string, extension?: string, complaintId?: int, position?: int}
Returns the relative path of a complaint attachment for its owner.
getGraphDataKeys()  : array<string|int, string>
Returns the keys an external client may send nested in `data`.
getLanguageContext()  : LanguageContext|null
getOrderPrefillData()  : array{success: bool, message?: string, customer?: array, items?: list>}
Prefills complaint items from an order picked in the admin.
getPresenterContext()  : PresenterContext
getServiceClass()  : class-string
Returns the fully qualified service class name, the identity for the per-service rollout allowlist of the wire-payload query cache ({@see \App\Core\Cache\Config::isServiceEnabled()}).
getSettingContext()  : SettingContext
getSettings()  : array<int, array<string, mixed>>
getTotalCount()  : int|null
initializeAllContexts()  : void
isOwnedBy()  : bool
Tells whether the complaint `$complaintId` belongs to the user `$userId`.
listForUser()  : array{success: bool, complaints: list>, total: int}
Lists the user's own complaints.
prefillForUser()  : array{success: bool, message?: string, errorCode?: string, customer?: array, items?: list>, solutions?: list}
Returns order items with the remaining quantity that can still be complained about, plus the code list of preferred solutions (in the order language).
registerEvent()  : Result
rejectionReasonForNewFiles()  : string|null
Pre-write guard for new attachments, called by {@see \App\UI\Api\Eshop\Models\Managers\ComplaintManager::submitForUser()}.
save()  : Result
Saves a complaint; an update of an existing one runs under the per-complaint lock (`cms_eshop_complaint_<id>`).
setAppContext()  : $this
setAssetFormatRegistry()  : static
setCacheTagInvalidator()  : static
setInternalCallContext()  : static
setLanguageContext()  : $this
setParamsConfig()  : static
setPresenterContext()  : $this
setSettingContext()  : $this
submitForUser()  : Result
Creates a complaint from the front end; the only write entry from the front end.
updateAllRelations()  : Result
updateColumn()  : Result
updateColumns()  : Result
updateRelations()  : Result
acquireSourceLock()  : bool
Calls `GET_LOCK(name, timeout)` and returns true only on a definite 1 (0 = timed out or busy, null = error). `$timeout` is in seconds; 0 is non-blocking (try-lock, used by the expirer).
afterSave()  : Result|null
Runs inside the `save()` transaction: attachments first, then (front end submit only) the `CREATED` e-mail into the queue.
beforeDelete()  : Result|null
Refuses to hard-delete a complaint that has a stock return.
beforeSave()  : Result|null
Guards the generic `save` (admin form, Api `save`, internal calls): complaint status, stock return and the economic items after a return have their own paths that the generic object graph must not bypass.
beginRejectedUploads()  : array<int, array{name: string, reason: string}>
Starts a save ({@see save()}): the list of rejected items belongs to this save only; the outer one (a nested save of the same manager) is returned to the caller and restored after the save through {@see finishRejectedUploads()}.
bumpCacheTags()  : void
Bumps cache tags for the entity after commit: its class, the owned-cascade classes from metadata and the optional `additionalCacheTags()` of the manager.
bumpCacheTagsExplicit()  : void
Bumps the given classes explicitly (raw-DBAL paths with a cross-entity effect).
bumpCacheTagsForClass()  : void
Bumps cache tags by class name when there is no instance (bulk delete, `updateColumns()`, raw DBAL, tree operations).
classCacheTags()  : array<string|int, string>
Returns extra tags for a bump that has no entity, typically tree operations ({@see \App\Core\Traits\Api\Models\Managers\TreeTrait::treeTransaction()}) that rewrite `lft`/`rgt` on many rows.
connection()  : Connection
finishRejectedUploads()  : void
Ends a save: whatever {@see recordRejectedUpload()} wrote during it is added to the data of a successful result as `saveInfo.rejectedUploads` (the Api presenter passes it on to the caller, the admin builds a warning from it). A rejected item does not fail the save; the accepted files stay.
getDeletedId()  : int|null
Returns the id `$entity` had before the delete, from the snapshot in `$meta` that {@see delete()} hands to every hook. Null for an entity the snapshot does not know (not loaded by this delete, or without a simple id).
getService()  : Service<string|int, TEntity>
Returns the service backing the manager traits.
lockLogChannel()  : string
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_*`).
releaseSourceLock()  : void
Releases the advisory lock.
runInSourceTransaction()  : T
Runs a closure in one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / errno 1213).
associationId()  : int|null
blacklistRefusal()  : Result|null
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.
checkOrderEligibility()  : array<string, mixed>|Result
Common gate of `submitForUser()` and `prefillForUser()`: eligibility of the order status plus ownership.
columnsInvalidateCache()  : bool
complaintAssetDir()  : string
complaintFilesWriteResult()  : Result|null
Returns a failure for `afterSave()` (rollback) when the relation write failed, `null` on success.
complaintItemsChanged()  : bool
Tells whether the economic shape of the items differs from the DB.
complaintsWithStockReturn()  : array<int, int>
Returns which complaints have a stock return: a `stock_document_id`, or a Store document with source `4/complaint/<id>` in any state.
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.
discardPendingComplaintFiles()  : void
Deletes files (and empty directories) written by the running save; called when it fails.
doSubmitForUser()  : Result
enqueueCreatedEmail()  : Result|null
Renders the `CREATED` e-mail and inserts it into the queue on the same connection inside the save transaction (a transactional outbox, like {@see ComplaintTransitionManager}).
expirationDays()  : int
fileByPosition()  : array{path: string, extension: string}|null
Resolves the attachment by position, with the same ordering and filter as {@see self::fileSummaryForDetail()} (`cf.order, file_id`, only `removed = 0`); `$position` is 1-based, so `OFFSET position - 1`.
fileDataResult()  : array{success: bool, errorCode?: string, message?: string, path?: string, extension?: string, complaintId?: int, position?: int}
fileSummaryForDetail()  : array<int, array{extension: string}>
Returns attachments for the customer detail, only order and extension.
forgetPendingComplaintFiles()  : void
The save finished (success) or was already cleaned up: files stop being pending.
isAssetPathAllowed()  : bool
Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.
ladd()  : numeric-string
lcmp()  : int
ldec()  : numeric-string
ldiv()  : numeric-string
Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).
lInt()  : int
lmul()  : numeric-string
ln()  : numeric-string
loadComplaintById()  : Complaint|null
Typed wrapper over {@see \App\Core\Traits\Api\Models\Managers\GetTrait::find()} that returns the concrete Api leaf entity, not `?BaseEntity`.
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.
lStr()  : string
lStrN()  : string|null
lsub()  : numeric-string
normalizeFileItems()  : array<int, array{type: string, fileId?: int, name?: string, data?: string, path?: string, order?: int}>
Normalizes `meta['files']`, which is Api/admin input, into a guaranteed shape.
orderItemProxy()  : OrderItem|null
Returns an `OrderItem` reference via `getReference()`, not `new OrderItem(); setId()`.
orderLanguageId()  : int
persistedComplaintState()  : array{statusId: int|null, stockDocumentId: int|null}|null
Returns the authoritative status and stock return from the DB; the row stays locked until the end of the `save()` transaction.
processComplaintFiles()  : Result|null
Processes the ordered attachment list (existing and new items in one array).
remainingQuantities()  : array<int, int>
Returns the remaining quantity per order item that can still be complained about.
rollbackBatch()  : void
saveWithHooks()  : Result
Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.
scalarInt()  : int
scalarStr()  : string
scalarStrN()  : string|null
solutionTranslationName()  : string|null
statusReturnsStock()  : bool
statusTranslationName()  : string|null
usesAssetPathTrait()  : bool
writeComplaintFiles()  : Result|null

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

COMPLAINTS_TABLE

private string COMPLAINTS_TABLE = 'cms_mod_eshop_complaints'

DEFAULT_EXPIRATION_DAYS

Fallback number of days until expiry when `4/complaint.expirationDays` is not set.

private int DEFAULT_EXPIRATION_DAYS = 30

DESCRIPTION_MIN_LENGTH

Length limits of the defect description, column `text`.

private int DESCRIPTION_MIN_LENGTH = 20

ELIGIBLE_ORDER_STATUS_KEYS

Order status keys from which a complaint may be submitted.

private array<string|int, mixed> ELIGIBLE_ORDER_STATUS_KEYS = ['SENT', 'DONE', 'RETURNED']

FK_STATUS

private string FK_STATUS = 'cms_mod_eshop_complaint_statuses_complaint_status_id'

ITEMS_TABLE

private string ITEMS_TABLE = 'cms_mod_eshop_complaint_items'

META_CREATED_EMAIL

Internal meta flag meaning "this complaint gets the `CREATED` e-mail"; set only by {@see self::doSubmitForUser()}.

private string META_CREATED_EMAIL = '__complaintCreatedEmail'

It cannot arrive from external HTTP save (filterExternalMeta() drops everything outside its allowlist, which is empty), and the admin form does not send it.

SAVE_LOCK_TIMEOUT

Per-complaint lock timeout; the lock name is the same as in {@see ComplaintTransitionManager} and {@see ComplaintStockManager}.

private int SAVE_LOCK_TIMEOUT = 5

STATUSES_TABLE

private string STATUSES_TABLE = 'cms_mod_eshop_complaint_statuses'

STORE_DOCUMENTS_TABLE

private string STORE_DOCUMENTS_TABLE = 'cms_mod_store_stock_documents'

STORE_SOURCE_MODULE

Source of the Store return document, see {@see ComplaintStockManager} (`SourceRef(4,'complaint',$id)`).

private int STORE_SOURCE_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

protected array<int, string> $defaultSkipFields = ['files']

$internalCallContext

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

protected InternalCallContext|null $internalCallContext = null

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

$nonInvalidatingColumns

Columns 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

$pendingComplaintDirs

Attachment directories created by this save (deleted when they stay empty).

private array<int, string> $pendingComplaintDirs = []

$pendingComplaintFiles

Absolute paths of files written during the currently running complaint save.

private array<int, string> $pendingComplaintFiles = []

A transaction rollback does not touch the disk, so when the save fails after a file was written, self::discardPendingComplaintFiles() deletes them; otherwise private/ would keep files with no row in cms_system_files. Emptied by ComplaintManager::save() on success.

$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(ComplaintService $service, FileManager $fileManager, OrderManager $orderManager, ComplaintStatusManager $complaintStatusManager, ComplaintSolutionManager $complaintSolutionManager, ComplaintEmailGenerator $emailGenerator, EmailQueueManager $emailQueueManager, EntityManagerInterface $em[, BlacklistManager|null $blacklistManager = null ]) : mixed
Parameters
$service : ComplaintService
$fileManager : FileManager
$orderManager : OrderManager
$complaintStatusManager : ComplaintStatusManager
$complaintSolutionManager : ComplaintSolutionManager
$emailGenerator : ComplaintEmailGenerator
$emailQueueManager : EmailQueueManager
$em : EntityManagerInterface
$blacklistManager : BlacklistManager|null = null

canUserWrite()

Tells whether `$userId` may write this complaint; the single input of the RBAC gate of the `save` action ({@see \App\UI\Api\Eshop\Presenters\ComplaintPresenter::verifyOwnership()}).

public canUserWrite(int|null $complaintId, int|null $orderId, int $userId) : bool
  • Insert ($complaintId is null): data.order.id must be the requester's order, verified from the DB by OrderManager::isOwnedBy().
  • Update: the complaint belongs to the requester and so does its order (self::isOwnedBy()); if the client also sent data.order.id, it must be the requester's order too, otherwise the complaint could be switched to a foreign one.

IMPORTANT: both ids come from the payload, but only as identifiers. Who the owner is comes from the DB alone; trusting payload ownership would make the guard prove nothing.

A status change does not pass here even when the write is allowed: Complaint::$status is #[ExternalReadonly] and strip removes it earlier.

Parameters
$complaintId : int|null

data.id; null means insert

$orderId : int|null

data.order.id (or data.orderId); null means not sent

$userId : int
Return values
bool

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 delete by filters (`api/eshop/complaint/delete-all`); it goes through {@see delete()}.

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

The generic DeleteTrait::deleteAll() does not run hooks and would bypass the stock return gate (same approach as ProductSetManager::deleteAll()).

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

detailForUser()

Returns the detail of the user's own complaint.

public detailForUser(int $complaintId, int $userId) : array{success: bool, message?: string, errorCode?: string, complaint?: array}

Ownership is double, see self::isOwnedBy(). adminNote is internal and never returned.

Parameters
$complaintId : int
$userId : int
Return values
array{success: bool, message?: string, errorCode?: string, complaint?: array}

find()

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

findAll()

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

entities, or raw rows when $columns is set

findBy()

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

findRelatedIds()

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

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

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

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 the owner data: the owner is the `$user` association (mapped in the Api leaf to `cms_system_users_user_id`), filled from the JWT on an external insert.

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

See Manager::getExternalOwnerData().

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>

getFileData()

Same for the admin, without the ownership gate (admin sees all complaints); the privilege is checked by {@see \App\UI\Api\Eshop\Presenters\ComplaintPresenter::internalActionFile()}.

public getFileData(int $complaintId, int $position) : array{success: bool, errorCode?: string, message?: string, path?: string, extension?: string, complaintId?: int, position?: int}
Parameters
$complaintId : int
$position : int
Return values
array{success: bool, errorCode?: string, message?: string, path?: string, extension?: string, complaintId?: int, position?: int}

getFileDataForUser()

Returns the relative path of a complaint attachment for its owner.

public getFileDataForUser(int $complaintId, int $userId, int $position) : array{success: bool, errorCode?: string, message?: string, path?: string, extension?: string, complaintId?: int, position?: int}

Ownership is double, as in self::detailForUser(). $position is the 1-based order in the listing (same ordering as self::fileSummaryForDetail(): cf.order, file_id, only removed = 0). It is never cms_system_files.file_id, which must not reach the URL.

Parameters
$complaintId : int
$userId : int
$position : int
Return values
array{success: bool, errorCode?: string, message?: string, path?: string, extension?: string, complaintId?: int, position?: int}

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>

getOrderPrefillData()

Prefills complaint items from an order picked in the admin.

public getOrderPrefillData(int $orderId) : array{success: bool, message?: string, customer?: array, items?: list>}

Every unit of an order row's quantity is expanded into its own complaint row (quantity=1), so each piece can be approved or rejected separately (ComplaintItem::$approved). It also returns the customer snapshot (Order::$customer* mapped to Complaint::$customer*, same field names).

Parameters
$orderId : int
Return values
array{success: bool, message?: string, customer?: array, items?: list>}

getServiceClass()

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

public getServiceClass() : class-string

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

Return values
class-string

getSettings()

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

getTotalCount()

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

isOwnedBy()

Tells whether the complaint `$complaintId` belongs to the user `$userId`.

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

IMPORTANT: ownership is double. The complaint must belong to the requester and its order must belong to the requester too. complaint.user_id alone is not enough, because Complaint::$order is an ON DELETE SET NULL reference snapshot, so a complaint could point to a deleted or foreign order and let the caller read or write foreign data through it.

Both are read from the DB by $complaintId, never from the payload.

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

listForUser()

Lists the user's own complaints.

public listForUser(int $userId[, int $limit = 20 ][, int $offset = 0 ]) : array{success: bool, complaints: list>, total: int}

$userId is server-authoritative (like OrderCheckoutManager::listForUser()); the WHERE is never widened.

Parameters
$userId : int
$limit : int = 20
$offset : int = 0
Return values
array{success: bool, complaints: list>, total: int}

prefillForUser()

Returns order items with the remaining quantity that can still be complained about, plus the code list of preferred solutions (in the order language).

public prefillForUser(int $orderId, int $userId) : array{success: bool, message?: string, errorCode?: string, customer?: array, items?: list>, solutions?: list}

It does not use getOrderPrefillData() (admin, full quantity without the cap); the front end asks a different question.

Parameters
$orderId : int
$userId : int
Return values
array{success: bool, message?: string, errorCode?: string, customer?: array, items?: list>, solutions?: list}

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

rejectionReasonForNewFiles()

Pre-write guard for new attachments, called by {@see \App\UI\Api\Eshop\Models\Managers\ComplaintManager::submitForUser()}.

public rejectionReasonForNewFiles(array<int, array<int|string, mixed>> $newFiles) : string|null

Runs on the whole list before the complaint is persisted so one bad file rejects the entire submit (no partial write). Returns null when all is fine, otherwise a ResultCode name (FILE_TYPE_NOT_ALLOWED / VALIDATION_FAILED).

An item carries data (base64) or path (file in the dropzone temp directory, internal calls only). Content is read one file at a time by Manager::readUploadedItem(); for path the size is checked with filesize() before reading.

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

bridge payload, untrusted input

Return values
string|null

save()

Saves a complaint; an update of an existing one runs under the per-complaint lock (`cms_eshop_complaint_<id>`).

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

The lock is the same as for status transitions and the stock return. Without it a stock return could slip in between the check in self::beforeSave() and the commit; the return reads items from its own fresh copy and the edit would then overwrite them. An insert needs no lock (no id yet).

This is also the only place that knows the save really finished: attachments written to disk in afterSave() are deleted on any failure (including a later one, the CREATED e-mail or the commit) and kept on success.

Parameters
$entity : BaseEntity
$meta : array<string, mixed> = []
$skipFields : array<int, string> = []
Return values
Result

setParamsConfig()

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

submitForUser()

Creates a complaint from the front end; the only write entry from the front end.

public submitForUser(int $userId, int $orderId, array<int, array<int|string, mixed>> $items, int|null $preferredSolutionId, string $description[, array<int, array<int|string, mixed>> $files = [] ]) : Result

IMPORTANT: the server builds the entity by hand from the OrderItem snapshot. The internal path does not strip #[ExternalReadonly], so a raw payload must never be hydrated here. The lock cms_eshop_complaint_order_<orderId> serializes concurrent submits for one order, so the quantity cap and the "at most one open complaint" rule see a consistent state.

Parameters
$userId : int
$orderId : int
$items : array<int, array<int|string, mixed>>

raw {orderItemId, quantity} pairs (untrusted JSON payload)

$preferredSolutionId : int|null
$description : string
$files : array<int, array<int|string, mixed>> = []

new attachments {name, data} (base64) or {name, path} (dropzone), without order/fileId

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

acquireSourceLock()

Calls `GET_LOCK(name, timeout)` and returns true only on a definite 1 (0 = timed out or busy, null = error). `$timeout` is in seconds; 0 is non-blocking (try-lock, used by the expirer).

protected acquireSourceLock(string $name, int $timeout) : bool
Parameters
$name : string
$timeout : int
Return values
bool

afterSave()

Runs inside the `save()` transaction: attachments first, then (front end submit only) the `CREATED` e-mail into the queue.

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

A failure of any part rolls back the whole complaint.

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

beforeDelete()

Refuses to hard-delete a complaint that has a stock return.

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

The Store document has only a loose reference source_module=4, source_type='complaint', source_id (no FK), so deleting would leave a posted document without a source. Both are checked: stock_document_id and the existence of the document (even a canceled one, because the accounting trail persists), by raw DBAL on ids, not from loaded entity values. It runs for a single id and an array of ids (DeleteTrait::delete() loads them all); bulk delete by filters is routed here through self::deleteAll().

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

beforeSave()

Guards the generic `save` (admin form, Api `save`, internal calls): complaint status, stock return and the economic items after a return have their own paths that the generic object graph must not bypass.

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

It reads through raw DBAL (FOR UPDATE, inside the save() transaction), never find(), because every Api read does UnitOfWork::clear() and would detach the entity being saved.

  • A status change goes only through ComplaintTransitionManager::apply() (return, e-mail, lock), so an update with a status different from the DB gives statusTransitionRequired. An insert may choose the initial status, but not one with returnStock=1 (the return would be skipped): initialStatusReturnsStock.
  • stock_document_id is written only by ComplaintStockManager; a payload value is ignored (insert gives null, update keeps the DB value).
  • With a stock_document_id the items (row set, approved, quantity, sale card, order row) are locked: itemsLockedAfterReturn. Note, contact and attachments stay editable.
Parameters
$isInsert : bool
$entity : Complaint
$meta : array<string, mixed> = []
Return values
Result|null

beginRejectedUploads()

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

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

the outer save's list

bumpCacheTags()

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

protected bumpCacheTags(object $entity) : void

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

Parameters
$entity : object

bumpCacheTagsExplicit()

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

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

bumpCacheTagsForClass()

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

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

Bumps the root class and its owned-cascade classes.

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

classCacheTags()

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

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

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

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

Return values
array<string|int, string>

connection()

protected connection() : Connection
Return values
Connection

finishRejectedUploads()

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

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

list from beginRejectedUploads()

getDeletedId()

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

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

getService()

Returns the service backing the manager traits.

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

lockLogChannel()

protected lockLogChannel() : string
Return values
string

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

releaseSourceLock()

Releases the advisory lock.

protected releaseSourceLock(string $name) : void

IMPORTANT: it is called from a finally, so a failure here (a gone-away connection after a fatal query) is swallowed and logged; rethrowing would mask the original exception being unwound. MySQL drops session locks when the connection dies anyway, so nothing leaks past the process.

Parameters
$name : string

runInSourceTransaction()

Runs a closure in one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / errno 1213).

protected runInSourceTransaction(callable(Connection): T $fn) : T

The advisory lock (held by the caller across this call) is not released by a rollback, so every attempt runs under the same serialization.

Parameters
$fn : callable(Connection): T
Tags
template
Return values
T

associationId()

private static associationId(mixed $association) : int|null
Parameters
$association : mixed
Return values
int|null

blacklistRefusal()

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

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>

checkOrderEligibility()

Common gate of `submitForUser()` and `prefillForUser()`: eligibility of the order status plus ownership.

private checkOrderEligibility(int $orderId, int $userId) : array<string, mixed>|Result

Returns the raw DBAL order row on success, otherwise a Result::failure with errorCode (orderNotFound / orderForbidden / orderNotEligible).

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

columnsInvalidateCache()

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

complaintAssetDir()

private complaintAssetDir(string $type) : string
Parameters
$type : string
Return values
string

complaintFilesWriteResult()

Returns a failure for `afterSave()` (rollback) when the relation write failed, `null` on success.

private complaintFilesWriteResult(Result $result) : Result|null
Parameters
$result : Result
Return values
Result|null

complaintItemsChanged()

Tells whether the economic shape of the items differs from the DB.

private complaintItemsChanged(int $complaintId, Complaint $entity) : bool

The shape is the row set by id, approved, quantity, sale card and order row. A new row (no id) or a missing row counts as a change. Name, code and prices are only a display snapshot; the stock return does not derive from them.

Parameters
$complaintId : int
$entity : Complaint
Return values
bool

complaintsWithStockReturn()

Returns which complaints have a stock return: a `stock_document_id`, or a Store document with source `4/complaint/<id>` in any state.

private complaintsWithStockReturn(array<int, int> $complaintIds) : array<int, int>
Parameters
$complaintIds : array<int, int>
Return values
array<int, int>

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

discardPendingComplaintFiles()

Deletes files (and empty directories) written by the running save; called when it fails.

private discardPendingComplaintFiles() : void

Best-effort: what cannot be deleted is logged (cleanup must not mask the original error).

doSubmitForUser()

private doSubmitForUser(int $userId, int $orderId, array<int, array<int|string, mixed>> $items, int|null $preferredSolutionId, string $description, array<int, array<int|string, mixed>> $files) : Result
Parameters
$userId : int
$orderId : int
$items : array<int, array<int|string, mixed>>
$preferredSolutionId : int|null
$description : string
$files : array<int, array<int|string, mixed>>
Return values
Result

enqueueCreatedEmail()

Renders the `CREATED` e-mail and inserts it into the queue on the same connection inside the save transaction (a transactional outbox, like {@see ComplaintTransitionManager}).

private enqueueCreatedEmail(int $complaintId) : Result|null

An exception from rendering or the queue becomes the failure submitFailed and rolls the complaint back; the customer repeats the submit. A status without sendEmail and a template that cannot be rendered (null, the generator logs the error itself) succeed without an e-mail.

IMPORTANT: never go through ComplaintTransitionManager::apply() here. A complaint that has been CREATED since its creation would be evaluated as a noop and the e-mail would never be rendered.

Parameters
$complaintId : int
Return values
Result|null

fileByPosition()

Resolves the attachment by position, with the same ordering and filter as {@see self::fileSummaryForDetail()} (`cf.order, file_id`, only `removed = 0`); `$position` is 1-based, so `OFFSET position - 1`.

private fileByPosition(int $complaintId, int $position) : array{path: string, extension: string}|null

It returns a relative path (the calling presenter converts it with AssetPathResolver::toAbsolute(), like MyOrderListPresenter::actionDownload()), never an absolute one: attachments live outside the web root (see ComplaintFileTrait).

Parameters
$complaintId : int
$position : int
Return values
array{path: string, extension: string}|null

fileDataResult()

private fileDataResult(int $complaintId, int $position) : array{success: bool, errorCode?: string, message?: string, path?: string, extension?: string, complaintId?: int, position?: int}
Parameters
$complaintId : int
$position : int
Return values
array{success: bool, errorCode?: string, message?: string, path?: string, extension?: string, complaintId?: int, position?: int}

fileSummaryForDetail()

Returns attachments for the customer detail, only order and extension.

private fileSummaryForDetail(int $complaintId) : array<int, array{extension: string}>

The original file name is not stored (cms_system_files.file_name is random); download goes through self::getFileDataForUser().

Parameters
$complaintId : int
Return values
array<int, array{extension: string}>

forgetPendingComplaintFiles()

The save finished (success) or was already cleaned up: files stop being pending.

private forgetPendingComplaintFiles() : void

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

ladd()

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

ldiv()

Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).

private ldiv(string $a, string $b) : numeric-string

IMPORTANT: truncation would systematically drop up to 0.001 from every unit on large quantities, so the invariant sum(unitPrice x quantity) ~ sum(rowBase) would drift in one direction (an issue document undervalued against the order).

Parameters
$a : string
$b : string
Return values
numeric-string

lmul()

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

loadComplaintById()

Typed wrapper over {@see \App\Core\Traits\Api\Models\Managers\GetTrait::find()} that returns the concrete Api leaf entity, not `?BaseEntity`.

private loadComplaintById(int $complaintId[, array<int, string> $includes = [] ]) : Complaint|null
Parameters
$complaintId : int
$includes : array<int, string> = []
Return values
Complaint|null

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>

lsub()

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

normalizeFileItems()

Normalizes `meta['files']`, which is Api/admin input, into a guaranteed shape.

private static normalizeFileItems(array<string|int, mixed> $files) : array<int, array{type: string, fileId?: int, name?: string, data?: string, path?: string, order?: int}>

An item without a textual type is not an attachment and is skipped.

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

orderItemProxy()

Returns an `OrderItem` reference via `getReference()`, not `new OrderItem(); setId()`.

private orderItemProxy(int $orderItemId) : OrderItem|null

ComplaintItem is a cascade-persist child of Complaint::$items (not a root entity of save()), so on flush Doctrine walks its associations too and rejects an unmanaged new object without cascade: persist (ORMInvalidArgumentException::newEntitiesFoundThroughRelationships()). getReference() returns a lazy proxy the UnitOfWork already knows, and the FK is written without a SELECT.

Parameters
$orderItemId : int
Return values
OrderItem|null

persistedComplaintState()

Returns the authoritative status and stock return from the DB; the row stays locked until the end of the `save()` transaction.

private persistedComplaintState(int $complaintId) : array{statusId: int|null, stockDocumentId: int|null}|null
Parameters
$complaintId : int
Return values
array{statusId: int|null, stockDocumentId: int|null}|null

processComplaintFiles()

Processes the ordered attachment list (existing and new items in one array).

private processComplaintFiles(Complaint &$entity, array<int, array{type: string, fileId?: int, name?: string, data?: string, path?: string, order?: int}> $orderedFiles) : Result|null

Item shape: {type: 'existing', fileId, order} or {type: 'new', name, data|path, order}. Returns null when everything is stored, otherwise a failure that ComplaintManager::afterSave() propagates so SaveTrait rolls back the whole complaint; a bad new attachment or failed File save must never leave a confirmed complaint without its attachment. Files written by this call are deleted at once on failure or exception (the exception propagates); on a later save failure ComplaintManager::save() deletes them.

Parameters
$entity : Complaint
$orderedFiles : array<int, array{type: string, fileId?: int, name?: string, data?: string, path?: string, order?: int}>
Return values
Result|null

remainingQuantities()

Returns the remaining quantity per order item that can still be complained about.

private remainingQuantities(int $orderId, array<int, int> $orderItemIds) : array<int, int>

Remaining = OrderItem.quantity minus the sum of ComplaintItem.quantity for that order item over complaints whose status key is not REJECTED. It counts complaints regardless of which order they belong to, which is reliable because orderItem links to the concrete row.

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

order item id mapped to the remaining number of pieces

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

scalarInt()

private scalarInt(mixed $value) : int
Parameters
$value : mixed
Return values
int

scalarStr()

private scalarStr(mixed $value[, string $default = '' ]) : string
Parameters
$value : mixed
$default : string = ''
Return values
string

scalarStrN()

private scalarStrN(mixed $value) : string|null
Parameters
$value : mixed
Return values
string|null

statusReturnsStock()

private statusReturnsStock(int $statusId) : bool
Parameters
$statusId : int
Return values
bool

usesAssetPathTrait()

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

writeComplaintFiles()

private writeComplaintFiles(Complaint $entity, int $complaintId, array<int, array{type: string, fileId?: int, name?: string, data?: string, path?: string, order?: int}> $orderedFiles) : Result|null
Parameters
$entity : Complaint
$complaintId : int
$orderedFiles : array<int, array{type: string, fileId?: int, name?: string, data?: string, path?: string, order?: int}>
Return values
Result|null
On this page

Search results