CoolCMS API

WorkListManager extends Manager
in package
uses SourceLockTransactionTrait

Manages work lists (jobs) and invoices selected work sheets of a job as one invoice.

A job is either open or archived; the lifecycle of its sheets lives in WorkSheetState. See invoiceSheets() for the invoicing steps and crash behavior.

IMPORTANT: invoicing handles money. A duplicate invoice burns a number from the live billing line, reaches the customer and can only be undone by a manual cancellation in Invoicer.

IMPORTANT: the job lock GET_LOCK() (lockName()) and every write must use the same DBAL connection, otherwise the lock protects nothing. State is read raw, not through Doctrine: the identity map may hold a stale row and the lock protects writes, not data freshness.

IMPORTANT: an archived job is read-only in the manager, not only in the UI: save(), updateColumns() (skips hooks), beforeDelete() and deleteAll() are guarded. Skipping any of them lets inline grid edits or filter deletes modify an archived job.

Tags
extends

Table of Contents

Constants

CUSTOMER_MISSING_MESSAGE  : string = 'Zakázka nemá klienta — vyfakturovat ji nelze....
Message of the gate "no invoicing without a customer"; shared with {@see CompanyInvoiceGenerator}.
WORK_LISTS_TABLE  : string = 'cms_mod_company_work_lists'
Own module table; raw DBAL is allowed only on it.
DELETED_IDS_META  : string = '__deletedIds'
Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.
FOLLOW_UP_NAME_FORMAT  : string = 'Pracovní list %d'
Default name of the follow-up sheet; `%d` is the sequence number within the job.
INVOICE_ITEMS_TABLE  : string = 'cms_mod_invoicer_invoice_items'
Invoice lines; read-only probe of the item count during recovery (no writes across the module boundary).
INVOICES_TABLE  : string = 'cms_mod_invoicer_invoices'
IMPORTANT: foreign table (Invoicer), used only as an existence probe of an invoice in {@see invoiceRowExists()}: a read-only `select invoice_id`, never a write, never reading invoice content (that belongs to `InvoiceManager::find()`). Doctrine cannot answer this: after a rollback inside `InvoiceManager::save()` the identity map still holds the invoice entity, so `find()` would report a nonexistent row as existing, and the answer decides between a safe revert and a second invoice.
LOCK_MAX_ATTEMPTS  : int = 3
LOCK_SCALE  : int = 3
LOCK_TIMEOUT  : int = 10
Seconds to wait for the job advisory lock. Longer than for reservations (5 s): the PDF is generated under the lock, so a concurrent click on issuing an invoice should wait, not fail.
NAME_MAX_LENGTH  : int = 255
`name` is `varchar(255)`; a longer name would be truncated by MySQL.
WORK_SHEETS_TABLE  : string = 'cms_mod_company_work_sheets'
Same table as {@see WorkSheetManager::WORK_SHEETS_TABLE}; invoicing writes into the sheets.

Properties

$service  : Service<string|int, TEntity>
Manager service; subclasses narrow the type (`@property` in the Api/Admin/Front manager).
$appContext  : AppContext
$assetFormatRegistry  : AssetFormatRegistry|null
Format processor registry for file uploads, used by {@see \App\Core\Traits\Api\Models\Managers\AssetProcessingTrait}.
$cacheTagInvalidator  : CacheTagInvalidator|null
Post-commit invalidator of the wire-payload query cache (tag bump per entity class).
$defaultSkipFields  : array<string|int, string>
$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
$companyManager  : CompanyManager
$em  : EntityManagerInterface
$invoiceGenerator  : CompanyInvoiceGenerator
$invoiceManager  : InvoiceManager
$invoicerCustomerManager  : CustomerManager
$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`.
$workItemManagerFactory  : WorkItemManagerFactory
$workSheetManager  : WorkSheetManager

Methods

__construct()  : mixed
archive()  : Result
Archives the job: `open -> archived`. The end of the job, not an invoicing action.
customerMissingRefusal()  : Result|null
Gate "a job without a customer is not invoiced": saving does not require a customer, invoicing does. Pure function over the job row (`fetchListRow()`), testable without a DB. `null` = pass.
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
Read-only guard for `deleteAll()`, which bypasses hooks yet is reachable over HTTP.
deleteRefusal()  : Result|null
Returns the reason to refuse deleting a non-archived job based on its non-deleted sheets (pure function, no DB). Invoiced sheets take precedence: deleting sheets does not resolve them, the only way is archiving. `null` = deletion allowed.
find()  : T|null
findAll()  : T> : list>)
findBy()  : T|null
findForUser()  : WorkList>, totalCount: int, itemCounts: array}
Returns the logged-in client's jobs (open and archived), newest first, with work item counts.
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 `data` keys that fill the owner from the server when an external save creates a record.
getExternalRemoteIpData()  : array<string, mixed>
Returns `data` keys that fill the caller's IP from the server when an external save creates a record; sibling of {@see getExternalOwnerData()}.
getExternalUpdateColumnsAllowlist()  : array<string|int, string>
Returns the columns the external HTTP path may change through {@see \App\Core\Traits\Api\Presenters\SaveTrait::actionUpdateColumns()}.
getGraphDataKeys()  : array<string|int, string>
Returns the keys an external client may send nested in `data`.
getLanguageContext()  : LanguageContext|null
getPresenterContext()  : PresenterContext
getServiceClass()  : class-string
Returns the fully qualified service class name, the identity for the per-service rollout allowlist of the wire-payload query cache ({@see \App\Core\Cache\Config::isServiceEnabled()}).
getSettingContext()  : SettingContext
getSettings()  : array<int, array<string, mixed>>
getTotalCount()  : int|null
initializeAllContexts()  : void
invoiceSheets()  : Result
Issues one invoice from the selected work sheets of one job.
isOwnedBy()  : bool
Returns whether the job belongs to the user (a boolean, not the entity).
lockName()  : string
Single source of truth for the name of the job advisory lock.
previewInvoiceSheets()  : Result
Summarizes the selected sheets before invoicing, exactly what the modal shows: items, hours, what goes on the invoice and how many items are skipped.
registerEvent()  : Result
save()  : Result
setAppContext()  : $this
setAssetFormatRegistry()  : static
setCacheTagInvalidator()  : static
setInternalCallContext()  : static
setLanguageContext()  : $this
setParamsConfig()  : static
setPresenterContext()  : $this
setSettingContext()  : $this
unarchive()  : Result
Returns the job from the archive: `archived -> open`.
updateAllRelations()  : Result
updateColumn()  : Result
updateColumns()  : Result
Read-only guard for `updateColumns()` as well. Without it the guard in `beforeSave()` would be decoration: `updateColumns` skips save hooks, and external Api `updateColumns` calls and inline changes from the admin grid come this way (same approach as {@see CompanyManager::updateColumns()}, which enforces exclusivity of `presented`).
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).
beforeDelete()  : Result|null
Read-only guard for deletion, plus: a job with non-deleted sheets is not deleted.
beforeSave()  : Result|null
Write guard of the job: nothing may be written into an archived one.
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).
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.
classifyStatesAmong()  : WorkListState>, 1: array}
Splits the raw states of the given jobs (one query, not N) into two maps `id => ...`: locked and unknown. The guard must see the current state, not what the identity map holds.
closingInvoiceIdOf()  : int|null
Invoice common to all `closing` sheets of the selection, or `null` (no `closing` sheet, no link, or more than one link).
closingRowsOf()  : array<int, array<string, mixed>>
Rows of the selection in state `closing` (recovery).
columnsInvalidateCache()  : bool
createFollowUpSheet()  : array{sheetId: ?int, error: ?string}
Step 10: follow-up empty sheet.
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.
deleteGuard()  : Result|null
Shared body of the delete guard (`beforeDelete()` and `deleteAll()`).
failure()  : Result
fetchListRow()  : array<string, mixed>|null
Authoritative job row (raw DBAL, see class docblock).
fetchSheetRows()  : array<int, array<string, mixed>>
Authoritative rows of the selected sheets in one query, as a map `sheetId => row`.
finishClosing()  : int
Step 9: finish. Invoice link, `state = 'invoiced'`, `bill = 0` and the stamp, all in one statement, so link and state appear together (see class docblock).
groupItemsBySheet()  : array<int, WorkItem>}>
Groups items under their sheets, the generator input shape: `['sheet' => WorkSheet, 'items' => WorkItem[]]` in sheet order.
guardInvoiceEntry()  : Result|array{list, array}
Input guards of invoicing, shared by the write path ({@see runInvoiceSheets()}) and the read-only preview ({@see previewInvoiceSheets()}).
guardSelectedSheets()  : Result|null
Guards of the selected sheets (step 2b): existence, membership in the job, deletion, unknown state and already invoiced. `null` means pass.
handleInvoiceSaveFailure()  : Result
Handles a failed `InvoiceManager::save()` and decides whether the invoice was created.
intOrNull()  : int|null
invoicedRowsOf()  : array<int, array<string, mixed>>
Rows of the selection in state `invoiced` linked exactly to invoice `$invoiceId`: the finished part of an interrupted run.
invoiceRowExists()  : bool
Whether the invoice row exists (see {@see self::INVOICES_TABLE}).
invoiceSuccess()  : Result
Uniform shape of the invoicing success: flat scalar data, so that the presenter only forwards it.
isAssetPathAllowed()  : bool
Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.
itemIds()  : array<int, int>
Sorted item ids, a comparable fingerprint of the selection. The order is normalized so that a mismatch is decided by the set, not by the random ordering of two queries.
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
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.
loadSheetsInOrder()  : array<int, WorkSheet>
Entities of the selected sheets ordered by `work_sheets.order` (then id), the order in which their items go on the invoice.
lockedFailure()  : Result
Uniform message of the read-only guard. An archived job is locked on purpose: it is a closed case and a silent edit would make the records diverge from what was already invoiced.
logDetachedInvoiceLinks()  : void
Logs the old invoice links that step 5 detaches, always before the update ({@see transitionToClosing()}).
lStr()  : string
lStrN()  : string|null
lsub()  : numeric-string
normalizeIds()  : array<int, int>
now()  : string
recoverInterrupted()  : Result
Recovery: at least one selected sheet is in `closing` (step 2c).
revertClosing()  : int
Reverts from `closing` to the original state with one update, again with a binding order in `set`: the target state is read from `state_before_closing` first, then the column is cleared.
rollbackBatch()  : void
runInvoiceSheets()  : Result
Invoicing body under the lock; never call outside {@see invoiceSheets()}.
runLockedListTransition()  : Result
Common envelope of job transitions: lock -> existence -> unknown state -> body. Legacy values `closing`/`closed` (rows from before the migration) behave like a "not open" job, so the conditional update does not match them and the admin gets a readable fail.
saveWithHooks()  : Result
Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.
sumItemPrices()  : string
Sum of item prices for the intent log: an indicative check number for finding the invoice, not the invoiced amount, which the Invoicer calculator computes authoritatively (VAT, rounding).
transitionToClosing()  : bool
Step 5: bulk transition `open|closed -> closing` in one conditional update.
unknownStateFailure()  : Result
Fail-closed guard: a raw `state` value outside the enum. The message prints the DB value instead of silently substituting `Open`/`Archived`: the column is `not null`, so only a genuinely broken row gets here (manual update, migration typo) and the admin must know exactly what is in the DB instead of getting an archive message that points to the wrong button.
usesAssetPathTrait()  : bool

Constants

CUSTOMER_MISSING_MESSAGE

Message of the gate "no invoicing without a customer"; shared with {@see CompanyInvoiceGenerator}.

public string CUSTOMER_MISSING_MESSAGE = 'Zakázka nemá klienta — vyfakturovat ji nelze. Doplňte klienta' . ' v detailu zakázky (záložka Zakázka), nebo listy jen uzavřete.'

WORK_LISTS_TABLE

Own module table; raw DBAL is allowed only on it.

public string WORK_LISTS_TABLE = 'cms_mod_company_work_lists'

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

FOLLOW_UP_NAME_FORMAT

Default name of the follow-up sheet; `%d` is the sequence number within the job.

private string FOLLOW_UP_NAME_FORMAT = 'Pracovní list %d'

INVOICE_ITEMS_TABLE

Invoice lines; read-only probe of the item count during recovery (no writes across the module boundary).

private string INVOICE_ITEMS_TABLE = 'cms_mod_invoicer_invoice_items'

INVOICES_TABLE

IMPORTANT: foreign table (Invoicer), used only as an existence probe of an invoice in {@see invoiceRowExists()}: a read-only `select invoice_id`, never a write, never reading invoice content (that belongs to `InvoiceManager::find()`). Doctrine cannot answer this: after a rollback inside `InvoiceManager::save()` the identity map still holds the invoice entity, so `find()` would report a nonexistent row as existing, and the answer decides between a safe revert and a second invoice.

private string INVOICES_TABLE = 'cms_mod_invoicer_invoices'

LOCK_TIMEOUT

Seconds to wait for the job advisory lock. Longer than for reservations (5 s): the PDF is generated under the lock, so a concurrent click on issuing an invoice should wait, not fail.

private int LOCK_TIMEOUT = 10

NAME_MAX_LENGTH

`name` is `varchar(255)`; a longer name would be truncated by MySQL.

private int NAME_MAX_LENGTH = 255

WORK_SHEETS_TABLE

Same table as {@see WorkSheetManager::WORK_SHEETS_TABLE}; invoicing writes into the sheets.

private string WORK_SHEETS_TABLE = 'cms_mod_company_work_sheets'

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<string|int, string> $defaultSkipFields = ['state', 'closedDatetime']

$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

$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(WorkListService $service, EntityManagerInterface $em, CompanyManager $companyManager, WorkSheetManager $workSheetManager, WorkItemManagerFactory $workItemManagerFactory, CompanyInvoiceGenerator $invoiceGenerator, InvoiceManager $invoiceManager, CustomerManager $invoicerCustomerManager) : mixed
Parameters
$service : WorkListService
$em : EntityManagerInterface
$companyManager : CompanyManager
$workSheetManager : WorkSheetManager
$workItemManagerFactory : WorkItemManagerFactory
$invoiceGenerator : CompanyInvoiceGenerator
$invoiceManager : InvoiceManager
$invoicerCustomerManager : CustomerManager

archive()

Archives the job: `open -> archived`. The end of the job, not an invoicing action.

public archive(int $workListId) : Result

Invoices nothing, closes nothing and changes no sheet state (sheets are locked individually). The only extra effect: from then on the server rejects writes to the whole job, its sheets and items.

NOTE: closed_datetime on the job means "archived_datetime"; the column was not renamed on purpose (another alter without benefit).

Guard: no sheet of the job may be closing. Archiving in the middle of invoicing would make step 9 hit the read-only guard and leave the invoice without a link.

Runs under the same lock as invoicing, so archiving and issuing an invoice cannot meet halfway.

Parameters
$workListId : int
Return values
Result —

success data array{workListId: int, state: string}

customerMissingRefusal()

Gate "a job without a customer is not invoiced": saving does not require a customer, invoicing does. Pure function over the job row (`fetchListRow()`), testable without a DB. `null` = pass.

public static customerMissingRefusal(array<string, mixed> $listRow) : Result|null

The text and errorCode are the same as the safeguard in CompanyInvoiceGenerator; the gate just sits earlier (before item selection and before the preview) so that the admin gets the true reason at once.

Parameters
$listRow : array<string, mixed>
Return values
Result|null

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

Read-only guard for `deleteAll()`, which bypasses hooks yet is reachable over HTTP.

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

Manager::deleteAll() (DeleteTrait) maps the filter to ids and deletes them via service->deleteAll(), without beforeDelete, and internalActionDeleteAll() is a normal Api action with its own RBAC key; the delete guard could otherwise be bypassed with a filter instead of ids. The affected set is therefore determined by the same filter and passes the same test. Behavior for unaffected sets (no archived or non-empty job in the selection) is identical to the parent.

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

deleteRefusal()

Returns the reason to refuse deleting a non-archived job based on its non-deleted sheets (pure function, no DB). Invoiced sheets take precedence: deleting sheets does not resolve them, the only way is archiving. `null` = deletion allowed.

public static deleteRefusal(int $workListId, int $sheetCount, int $invoicedSheetCount) : Result|null
Parameters
$workListId : int
$sheetCount : int
$invoicedSheetCount : int
Return values
Result|null

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

findForUser()

Returns the logged-in client's jobs (open and archived), newest first, with work item counts.

public findForUser(int $userId[, int|null $offset = null ][, int|null $limit = null ]) : WorkList>, totalCount: int, itemCounts: array}

IMPORTANT: $userId is the identity from the JWT set by the presenter, never a claim of the caller. The manager does not read the JWT itself, but callers must take $userId from getJwtPayload(); anything else lets a caller read another client's jobs.

Scope is the chain user -> their customers -> jobs: CustomerManager::findIdsForUser() supplies the customer ids (Company does not join Invoicer tables) and jobs are filtered by invoicerCustomerId IN (...). The caller passes no filters, so there is nothing to widen. $userId <= 0 and a user without customers return empty without querying jobs: without identity there is nothing to own, and a job without a customer (invoicer_customer_id IS NULL) must not land in anyone's list.

Item counts come from one aggregate query (WorkItemManager::countActiveForLists()), not N queries and not in PHP; items are not loaded at all (the client sees them in the detail).

Parameters
$userId : int
$offset : int|null = null
$limit : int|null = null
Return values
WorkList>, totalCount: int, itemCounts: array}

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 `data` keys that fill the owner from the server when an external save creates a record.

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

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

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

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

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

Parameters
$userId : int

identity from the JWT (never from the request)

Return values
array<string, mixed>

getExternalRemoteIpData()

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

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

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

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

getExternalUpdateColumnsAllowlist()

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

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

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

Return values
array<string|int, string>

getGraphDataKeys()

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

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

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

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

Return values
array<string|int, string>

getServiceClass()

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

public getServiceClass() : class-string

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

Return values
class-string

getSettings()

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

getTotalCount()

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

invoiceSheets()

Issues one invoice from the selected work sheets of one job.

public invoiceSheets(int $workListId, array<int, int> $sheetIds[, bool $createFollowUpSheet = false ][, string|null $followUpName = null ]) : Result

The whole run is under the job advisory lock, so a double click ends with one invoice and one readable rejection. A sheet in progress may be invoiced as well: it is closed with its unfinished items, which are not carried over. Rows are selected only by WorkItemManager::findBillableForSheets(), so the invoice modal and this code read the identical list.

Steps:

  1. take the job lock
  2. read the job and all selected sheets raw, run guards (recovery branch, see recoverInterrupted())
  3. select billable items across the sheets
  4. build the invoice: pure builder, no writes; runs before step 5 so that bad data yields a specific error over still-open sheets instead of sheets stuck in closing
  5. open|closed -> closing: one conditional bulk update, affected == count(sheetIds) (stores the original state in state_before_closing, detaches the old invoice link)
  6. re-check the item selection (time-of-check race); mismatch -> revert + fail
  7. log the intent, then InvoiceManager::save()
  8. save failed -> probe whether the invoice row exists -> revert to state_before_closing
  9. one update: invoicer_invoice_id + state invoiced + bill = 0 + closed_datetime; link and state appear together or not at all
  10. optionally create a follow-up empty sheet (order = max + 1)

After a crash:

  • between 5 and 7: closing, no invoice id; the next call reverts and fails with a message.
  • save failed, invoice not created: reverted, can be invoiced normally.
  • between 7 and 9: closing, no invoice id; manual verification needed (below).
  • after a manual fix of the link: closing with a filled id; the next call finishes it.
  • between 9 and 10: invoiced; rejected with alreadyInvoiced, the admin creates the follow-up sheet by hand.

IMPORTANT: a crash between 5 and 7 and a crash between 7 and 9 leave the same row (closing without invoice id), so the server cannot tell whether the invoice exists. The next call reverts such sheets to state_before_closing (recoverInterrupted(), situation 2) and fails with closingWithoutInvoice, warning the admin to verify in Invoicer before repeating; repeating after a crash between 7 and 9 would issue a duplicate. If the invoice exists, the admin sets invoicer_invoice_id and state = 'closing' on all affected sheets in the DB and repeats, which finishes them without a new invoice. Only a failed save (step 8) reverts with proof (invoiceRowExists()). The intent is logged before saving and the number right after, both to the company channel.

IMPORTANT: there is no wrapping transaction on purpose. InvoiceManager::save() opens its own and builds PDF and QR after commit; nested, a rollback would leave a file for a nonexistent invoice and the numbering row lock would serialize issuing for the whole system.

Parameters
$workListId : int
$sheetIds : array<int, int>

sheets to invoice

$createFollowUpSheet : bool = false

create a follow-up empty sheet

$followUpName : string|null = null

its name (null = default "Work sheet N")

Return values
Result —

success data array{workListId: int, sheetIds: list<int>, invoiceId: ?int, invoiceNumber: ?string, invoicedItems: int, invoicedSheets: int, followUpSheetId: ?int, followUpError: ?string, recovered: bool}; failure carries errorCode in the data

isOwnedBy()

Returns whether the job belongs to the user (a boolean, not the entity).

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

Read raw by job id: only the DB is trusted, never an entity sent in a payload. $userId <= 0 is always false, so a job without a customer (invoicer_customer_id IS NULL) cannot match "userId = 0". Ownership is derived through user -> customers (CustomerManager::findIdsForUser()) -> the job's invoicer_customer_id.

NOTE: no direct Api consumer at the moment (WorkItemManager::findForOwnedList() does ownership and item selection in one query); kept as a shared ownership primitive for actions that need only a boolean.

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

lockName()

Single source of truth for the name of the job advisory lock.

public static lockName(int $workListId) : string

Two classes take the lock: invoicing here and manual sheet transitions in WorkSheetManager. If each composed the name itself, one typo would let both operations run in parallel over the same sheets. public static because WorkSheetManager needs it without a DI dependency on this manager (cycle). MariaDB holds a lock name up to 192 characters.

Parameters
$workListId : int
Return values
string

previewInvoiceSheets()

Summarizes the selected sheets before invoicing, exactly what the modal shows: items, hours, what goes on the invoice and how many items are skipped.

public previewInvoiceSheets(int $workListId, array<int, int> $sheetIds) : Result

The rule "what goes on the invoice" is not duplicated: billable items are selected by the same WorkItemManager::findBillableForSheets() that the invoicing then uses. Otherwise the admin could approve a different list than the one invoiced, which is also what the re-check in step 6 guards against.

skippedItems = items marked billable that have no price (no hours and no manual price, or a zero price), so they will not reach the invoice. They must be visible, otherwise the sheet would be closed and the admin would find out only with the next invoice.

Read-only, no write and no lock.

Parameters
$workListId : int
$sheetIds : array<int, int>
Return values
Result —

success data array{workListId: int, sheets: list<array{workSheetId: int, name: string, state: string, bill: bool, items: int, billableItems: int, skippedItems: int, hours: string, total: string}>, items: int, billableItems: int, skippedItems: int, hours: string, total: string}

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

save()

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

setParamsConfig()

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

unarchive()

Returns the job from the archive: `archived -> open`.

public unarchive(int $workListId[, string|null $reason = null ]) : Result

closed_datetime (meaning "archived_datetime") is cleared. Sheet states do not change, so invoiced sheets stay invoiced.

Parameters
$workListId : int
$reason : string|null = null

optional reason from the confirmation modal; only logged to the company channel (the job has no history of its own and creating one for a single sentence would be more than needed)

Return values
Result —

success data array{workListId: int, state: string}

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

Read-only guard for `updateColumns()` as well. Without it the guard in `beforeSave()` would be decoration: `updateColumns` skips save hooks, and external Api `updateColumns` calls and inline changes from the admin grid come this way (same approach as {@see CompanyManager::updateColumns()}, which enforces exclusivity of `presented`).

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

archive() and unarchive() do not use this override (so they do not bypass it either): they need conditional updates (where state = 'open') that double as the serialization gate and that updateColumns cannot express.

Parameters
$entityId : int|array<int, int>
$values : array<string, mixed>
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

beforeDelete()

Read-only guard for deletion, plus: a job with non-deleted sheets is not deleted.

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

The FK fk_company_work_sheet_list is on delete restrict, so a hard delete would end in an unreadable SQL error, and restrict does not help a soft delete at all: orphan sheets (and items under them) would stay under a job that no UI shows. The guard enforces "sheets first, then the job" and tells the admin how many sheets remain.

IMPORTANT: deleting a job does not cascade (sheets, items, comments) by design. Deletion is hard and irreversible and clients see items and comments on the front end, so one grid click would destroy the record of done work and the correspondence. Restrict on fk_company_work_sheet_list and fk_company_work_item_sheet is deliberate and a cascade in PHP would bypass it. Invoiced (and closing) sheets document an issued invoice and must never disappear; the normal end of a job is archiving. The admin gets a concrete reason through a dedicated ResultCode (the grid translates it from the results domain, DataGrid::explainDeleteFailures()), see deleteRefusal().

The state is read raw, not from the passed entities: an entity may come from the identity map from before archiving, while the guard must see the DB now. Covers delete(id) and delete([ids]); for arrays DeleteTrait rolls back the whole batch on a failed beforeDelete, so one archived job blocks the open ones too (atomicity over partial cleanup).

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

beforeSave()

Write guard of the job: nothing may be written into an archived one.

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

The state is read from the DB, not from the entity: the entity comes from payload hydration where state is in skipFields, so it would carry an unverified value.

NOTE: there is no guard "one open sheet per (company, customer)": a company may have two concurrent jobs for one customer (two construction sites).

invoiceSheets(), archive() and unarchive() never get here; they write only through private conditional updates.

Parameters
$isInsert : bool
$entity : WorkList
$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

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>

classifyStatesAmong()

Splits the raw states of the given jobs (one query, not N) into two maps `id => ...`: locked and unknown. The guard must see the current state, not what the identity map holds.

private classifyStatesAmong(array<int, int> $ids) : WorkListState>, 1: array}

IMPORTANT: fail-closed. A raw state value outside the enum (WorkListState::tryFrom() returns null) goes to $unknown, not into silence: filtering in SQL by the valid locked values would drop it from every map and the guard would treat it as open. Callers must handle it with unknownStateFailure() before looking at $locked. The column is not null, so an empty value does not occur in practice.

Parameters
$ids : array<int, int>
Return values
WorkListState>, 1: array} —

[locked, unknown]

closingInvoiceIdOf()

Invoice common to all `closing` sheets of the selection, or `null` (no `closing` sheet, no link, or more than one link).

private closingInvoiceIdOf(array<int, array<string, mixed>> $sheetRows) : int|null

Input of guardSelectedSheets(): only it tells which invoiced sheets of the selection are the finished part of the same interrupted run (and must not fall to alreadyInvoiced) and which are real duplicates. Whether recovery can really be finished is decided by recoverInterrupted().

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

closingRowsOf()

Rows of the selection in state `closing` (recovery).

private closingRowsOf(array<int, array<string, mixed>> $sheetRows) : array<int, array<string, mixed>>
Parameters
$sheetRows : array<int, array<string, mixed>>
Return values
array<int, array<string, mixed>>

columnsInvalidateCache()

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

createFollowUpSheet()

Step 10: follow-up empty sheet.

private createFollowUpSheet(int $workListId, bool $create, string|null $name) : array{sheetId: ?int, error: ?string}

Created via WorkSheetManager::save(), not a raw insert, so that it passes its guards (archived job, mandatory job). order = max + 1 within the job, the default name follows the count of non-deleted sheets.

IMPORTANT: a failure does not fail the invoicing. At this moment the invoice is issued and the sheets are invoiced, both irreversible. Anything thrown from here (DBAL outage, EntityManager closed after a previous flush, the em_closed landmine) would otherwise end as a 500 on a request that has already issued the invoice: the admin would see an error page, the number from the live line would be burned and the state would have to be traced in the DB. The error is returned in the data (followUpError).

Parameters
$workListId : int
$create : bool
$name : string|null
Return values
array{sheetId: ?int, error: ?string}

deleteBatch()

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

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

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

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

deleteGuard()

Shared body of the delete guard (`beforeDelete()` and `deleteAll()`).

private deleteGuard(array<int, int> $ids) : Result|null
Parameters
$ids : array<int, int>
Return values
Result|null

fetchListRow()

Authoritative job row (raw DBAL, see class docblock).

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

fetchSheetRows()

Authoritative rows of the selected sheets in one query, as a map `sheetId => row`.

private fetchSheetRows(array<int, int> $sheetIds) : array<int, array<string, mixed>>
Parameters
$sheetIds : array<int, int>
Return values
array<int, array<string, mixed>>

finishClosing()

Step 9: finish. Invoice link, `state = 'invoiced'`, `bill = 0` and the stamp, all in one statement, so link and state appear together (see class docblock).

private finishClosing(array<int, int> $sheetIds, int $invoiceId) : int

invoicer_invoice_id is null or = :invoiceId in where makes the method idempotent and safe: it finishes sheets whose link a human filled after an interruption (recovery situation 1) but never overwrites a link to a different invoice. bill = 0 because "goes on the nearest invoice" is fulfilled once issued (the chip disappears).

Parameters
$sheetIds : array<int, int>
$invoiceId : int
Return values
int —

how many sheets were finished

groupItemsBySheet()

Groups items under their sheets, the generator input shape: `['sheet' => WorkSheet, 'items' => WorkItem[]]` in sheet order.

private groupItemsBySheet(array<int, WorkSheet> $sheets, array<int, WorkItem> $items) : array<int, WorkItem>}>

Sheets without billable items do not reach the invoice (nothing to contribute) but are invoiced with the others: the sheet is closed with its unfinished items.

Parameters
$sheets : array<int, WorkSheet>
$items : array<int, WorkItem>

already sorted (sheet order -> item order -> id)

Return values
array<int, WorkItem>}>

guardInvoiceEntry()

Input guards of invoicing, shared by the write path ({@see runInvoiceSheets()}) and the read-only preview ({@see previewInvoiceSheets()}).

private guardInvoiceEntry(int $workListId, array<int, int> $sheetIds) : Result|array{list, array}

One place keeps the messages in sync: the preview must not promise an invoice that invoiceSheets() would reject. Order: job id -> selection normalization -> job existence and deletion -> unknown state -> archive.

IMPORTANT: WorkListState::tryFrom() returning null is a hard fail (unknownState). Treating it as "not locked" would let a typo in the DB through; for money the only safe answer is "unknown, not touching it".

Parameters
$workListId : int
$sheetIds : array<int, int>

raw selection from the caller

Return values
Result|array{list, array} —

failure, or the pair [normalized sheet ids, job row]

guardSelectedSheets()

Guards of the selected sheets (step 2b): existence, membership in the job, deletion, unknown state and already invoiced. `null` means pass.

private guardSelectedSheets(int $workListId, array<int, int> $sheetIds, array<int, array<string, mixed>> $sheetRows[, int|null $recoveryInvoiceId = null ]) : Result|null

The whole selection is rejected, not only the faulty items: invoicing just some of what the admin ticked is worse than a readable rejection.

IMPORTANT: an unknown state value is a hard fail (unknownState), not a fall-through. A typo in the DB ("invoced") is neither invoiced nor closing and the sheet would be silently invoiced again.

$recoveryInvoiceId is the invoice carried by the closing sheets of the selection (closingInvoiceIdOf()). invoiced sheets with the same link are the finished part of an interrupted run, so alreadyInvoiced does not apply to them; otherwise the interrupted invoicing could not be finished by repeating the action (see runInvoiceSheets()).

Parameters
$workListId : int
$sheetIds : array<int, int>
$sheetRows : array<int, array<string, mixed>>

map sheetId => row

$recoveryInvoiceId : int|null = null

invoice common to the closing sheets of the selection (null = none)

Return values
Result|null

handleInvoiceSaveFailure()

Handles a failed `InvoiceManager::save()` and decides whether the invoice was created.

private handleInvoiceSaveFailure(int $workListId, array<int, int> $sheetIds, Invoice $invoice, Result $save) : Result

When beforeSave fails (all domain guards of Invoicer: missing billing line, duplicate number, decimal quantity guard), SaveTrait rolls back before anything is written, so the entity has no id and the invoice certainly does not exist. Such sheets return to their original state (state_before_closing) and can be invoiced after the fix.

An assigned id means the insert ran, not that the commit confirmed it. This last uncertain case is narrowed by a raw existence probe (invoiceRowExists()): when the row is missing (typically a rollback inside save()), the revert is provably safe. When it exists, closing is kept on purpose and a human decides: a revert would mean a second invoice, a costlier error than blocked sheets.

Parameters
$workListId : int
$sheetIds : array<int, int>
$invoice : Invoice
$save : Result
Return values
Result

intOrNull()

private intOrNull(mixed $value) : int|null
Parameters
$value : mixed
Return values
int|null

invoicedRowsOf()

Rows of the selection in state `invoiced` linked exactly to invoice `$invoiceId`: the finished part of an interrupted run.

private invoicedRowsOf(array<int, array<string, mixed>> $sheetRows, int $invoiceId) : array<int, array<string, mixed>>
Parameters
$sheetRows : array<int, array<string, mixed>>
$invoiceId : int
Return values
array<int, array<string, mixed>>

invoiceRowExists()

Whether the invoice row exists (see {@see self::INVOICES_TABLE}).

private invoiceRowExists(int $invoiceId) : bool

The answer is valid in the context of the current transaction of this session: after a rollback inside InvoiceManager::save() the row is not visible because it does not exist, which is exactly the question asked.

Parameters
$invoiceId : int
Return values
bool

invoiceSuccess()

Uniform shape of the invoicing success: flat scalar data, so that the presenter only forwards it.

private invoiceSuccess(int $workListId, array<int, int> $sheetIds, int|null $invoiceId, string|null $invoiceNumber, int $invoicedItems, array{sheetId: ?int, error: ?string} $followUp, bool $recovered[, string|null $message = null ]) : Result
Parameters
$workListId : int
$sheetIds : array<int, int>
$invoiceId : int|null
$invoiceNumber : string|null
$invoicedItems : int
$followUp : array{sheetId: ?int, error: ?string}
$recovered : bool
$message : string|null = null
Return values
Result

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

itemIds()

Sorted item ids, a comparable fingerprint of the selection. The order is normalized so that a mismatch is decided by the set, not by the random ordering of two queries.

private itemIds(array<int, WorkItem> $items) : array<int, int>
Parameters
$items : array<int, WorkItem>
Return values
array<int, int>

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

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>

loadSheetsInOrder()

Entities of the selected sheets ordered by `work_sheets.order` (then id), the order in which their items go on the invoice.

private loadSheetsInOrder(array<int, int> $sheetIds, array<int, array<string, mixed>> $sheetRows) : array<int, WorkSheet>
Parameters
$sheetIds : array<int, int>
$sheetRows : array<int, array<string, mixed>>

raw rows (carry order)

Return values
array<int, WorkSheet>

lockedFailure()

Uniform message of the read-only guard. An archived job is locked on purpose: it is a closed case and a silent edit would make the records diverge from what was already invoiced.

private lockedFailure(array<int, int> $ids, WorkListState|null $state) : Result

Archived is the only locked state (lockedStates()). A broken row with a value outside the enum does not get here; the fail-closed unknownState catches it earlier.

Parameters
$ids : array<int, int>
$state : WorkListState|null
Return values
Result

Logs the old invoice links that step 5 detaches, always before the update ({@see transitionToClosing()}).

private logDetachedInvoiceLinks(int $workListId, array<int, int> $sheetIds, array<int, array<string, mixed>> $sheetRows) : void

After detaching, nothing else records that the sheet once belonged to invoice #X: the column was the only place of that information. The company log is the only trace and must be written before the statement overwrites it.

Parameters
$workListId : int
$sheetIds : array<int, int>
$sheetRows : array<int, array<string, mixed>>

lsub()

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

normalizeIds()

private normalizeIds(array<int, int> $ids) : array<int, int>
Parameters
$ids : array<int, int>
Return values
array<int, int>

recoverInterrupted()

Recovery: at least one selected sheet is in `closing` (step 2c).

private recoverInterrupted(int $workListId, array<int, int> $sheetIds, array<int, array<string, mixed>> $sheetRows) : Result

Three situations, three answers:

  1. All selected are closing with the same filled invoicer_invoice_id: the invoice provably exists, only the state remains. Finished by step 9 (idempotent, no second invoice, recovered: true).
  2. All selected are closing without an invoice link: the server cannot tell whether the invoice was created. Sheets return to state_before_closing and a specific fail asks the admin to verify in Invoicer and run again. Continuing to a new invoice would be a duplicate if the invoice exists.
  3. Anything mixed (part closing, part not; different invoice ids; part with link, part without): hard fail closingMixedState. Automation would guess; a human decides.

IMPORTANT: partially finished finishClosing(). When step 9 succeeded only on some sheets, the rest of the selection is invoiced with invoice X and the closing part carries the same X. This is not a mixed state: invoiced sheets with the same link count as the finished part and only the closing rest is finished. Otherwise the "repeat the action on the same selection" message would send the admin to a selection that the server rejects as alreadyInvoiced.

The follow-up sheet is deliberately not created in recovery: the server does not know whether the interrupted run already created it, and two extra empty sheets are an unwelcome surprise.

Parameters
$workListId : int
$sheetIds : array<int, int>
$sheetRows : array<int, array<string, mixed>>

map sheetId => row of the whole selection

Return values
Result

revertClosing()

Reverts from `closing` to the original state with one update, again with a binding order in `set`: the target state is read from `state_before_closing` first, then the column is cleared.

private revertClosing(array<int, int> $sheetIds) : int

state_before_closing is not null in where guards against state = null (the column is not null): a row that reached closing other than via step 5 is left unchanged for a human instead of failing on the constraint.

NOTE: the revert restores the state, not the old invoice link that step 5 detached (transitionToClosing()). A reopened sheet whose re-invoicing failed keeps invoicer_invoice_id = null. This is deliberate: the column means "the invoice this sheet IS on" and the old invoice no longer represents it after the reopen. The historical link stays in the company log; keeping it in the same column would force finishClosing() to tell it apart from a live link, the very ambiguity that produced orphan invoices.

Parameters
$sheetIds : array<int, int>
Return values
int —

how many sheets were reverted

runInvoiceSheets()

Invoicing body under the lock; never call outside {@see invoiceSheets()}.

private runInvoiceSheets(int $workListId, array<int, int> $sheetIds, bool $createFollowUpSheet, string|null $followUpName) : Result
Parameters
$workListId : int
$sheetIds : array<int, int>

raw input, normalized by guardInvoiceEntry()

$createFollowUpSheet : bool
$followUpName : string|null
Return values
Result

runLockedListTransition()

Common envelope of job transitions: lock -> existence -> unknown state -> body. Legacy values `closing`/`closed` (rows from before the migration) behave like a "not open" job, so the conditional update does not match them and the admin gets a readable fail.

private runLockedListTransition(int $workListId, callable(int): Result $transition) : Result

IMPORTANT: a value that WorkListState::tryFrom() does not know is a hard fail unknownState before any write. The conditional update would not match it either, but the admin would get a misleading message ("job is not open" / "not archived") instead of learning that the DB holds a broken value.

Parameters
$workListId : int
$transition : callable(int): Result
Return values
Result

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

sumItemPrices()

Sum of item prices for the intent log: an indicative check number for finding the invoice, not the invoiced amount, which the Invoicer calculator computes authoritatively (VAT, rounding).

private sumItemPrices(array<int, WorkItem> $items) : string
Parameters
$items : array<int, WorkItem>
Return values
string

transitionToClosing()

Step 5: bulk transition `open|closed -> closing` in one conditional update.

private transitionToClosing(int $workListId, array<int, int> $sheetIds, array<int, array<string, mixed>> $sheetRows) : bool

affectedRows == count($sheetIds) proves that nobody changed any of the sheets meanwhile; anything less means "the state is no longer the one I started from" and is always a reason to stop. The caller then reverts even the sheets that got through (revertClosing()).

IMPORTANT: the order in the set clause is binding.

set state_before_closing = state,   -- 1) reads the original value
    invoicer_invoice_id = null,     -- 2) detaches the old link (re-invoicing after reopen)
    state = 'closing'               -- 3) only now overwrites the state

MySQL/MariaDB evaluate set assignments left to right and the right-hand sides see the row as already changed by earlier assignments of the same clause. Reversed, state_before_closing would store 'closing' and the revert would have nowhere to return a manually closed sheet to: it would open it.

The old invoice link is detached because WorkSheetManager::reopenSheet() keeps the original invoicer_invoice_id on a reopened sheet on purpose. Without detaching, re-invoicing would end with an orphan invoice: finishClosing() has invoicer_invoice_id IS NULL OR = :invoiceId in where and would reject a sheet with the old link, but only after the new invoice is saved and the number from the live line burned. Detaching is part of the same statement: either the sheet is detached and switched, or neither. The old invoice stays in Invoicer; only the link from the sheet disappears and it is logged to the company channel before the transition (the only trace). Recovery is unchanged: closing without a link is still the revert branch, correct because the new invoice does not exist at that moment.

NOTE: the conditional updates call bumpCacheTagsForClass() right after the statement, possibly before an outer transaction commits. A rollback then leaves a raised tag for a state that never happened: one harmless cache miss. Bumping after commit would risk a stale cache.

Parameters
$workListId : int
$sheetIds : array<int, int>
$sheetRows : array<int, array<string, mixed>>

raw rows of the selection (carry the old link)

Return values
bool

unknownStateFailure()

Fail-closed guard: a raw `state` value outside the enum. The message prints the DB value instead of silently substituting `Open`/`Archived`: the column is `not null`, so only a genuinely broken row gets here (manual update, migration typo) and the admin must know exactly what is in the DB instead of getting an archive message that points to the wrong button.

private unknownStateFailure(array<int, int> $ids, string $raw) : Result
Parameters
$ids : array<int, int>
$raw : string
Return values
Result

usesAssetPathTrait()

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

Search results