CoolCMS API

WorkItemManager extends Manager
in package

A single work item in a work sheet: hourly rate snapshot, server-side `price` and a read-only guard.

WorkList is the job (historical name), WorkSheet is a work sheet inside a job. The rate is snapshotted on creation (service, then company default), price is recomputed on every save and the work items of a closed sheet are read-only.

The parent of a work item is the sheet, not the job, so ownership and every guard follow the chain work item, sheet, job:

  • the write lock comes from the sheet state (WorkSheetState::lockedStates()),
  • a second, independent lock is the archived job: while the job is archived the server rejects writes to its sheets and work items (a read-only UI only mirrors the rule),
  • client ownership goes user, their customers, job via work_lists.invoicer_customer_id (customer ids come from CustomerManager::findIdsForUser()) and is resolved with a JOIN through the sheet.

IMPORTANT: work items of an invoiced (or being invoiced) sheet are locked, because their hours and price are already on the invoice and a silent edit would desynchronize document and records. The guard sits in every write path, because each one bypasses the others:

  • beforeSave() covers insert and update (form, Api save, admin bridge); an update checks both ends, the sheet the work item is in now (read raw from the DB) and the sheet the payload moves it to,
  • the updateColumns() override covers the grid inline change and the Api action (updateColumns() never runs save hooks) including the target sheet of a move,
  • beforeDelete() and the deleteAll() override cover deletion, which is physical and would remove an invoiced work item from the records.

Moving a work item between sheets has two independent guards: both ends must be unlocked, and the target sheet must belong to the same job (guardSheetOfSameList()), which also forbids a move between two jobs of the same client (money would cross invoicing contexts). No path leads a work item out of a locked sheet.

Tags
extends

Table of Contents

Constants

DELETED_IDS_META  : string = '__deletedIds'
Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.
PRICE_INPUT_FIELDS  : array<string|int, mixed> = ['actualHours', 'hourlyRate', 'manualPrice']
`updateColumns()` fields the work item price is computed from ({@see computePrice()}).
WORK_ITEMS_TABLE  : string = 'cms_mod_company_work_items'
Own tables of the module; raw DBAL is allowed only on them (`cs-conv-model`, layer boundaries).
WORK_LISTS_TABLE  : string = 'cms_mod_company_work_lists'
Same table as {@see WorkListManager::WORK_LISTS_TABLE}; used by the archived job guard.
WORK_SHEETS_TABLE  : string = 'cms_mod_company_work_sheets'
Same table as {@see WorkSheetManager::WORK_SHEETS_TABLE}; the guards read the sheet state raw.

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
$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`.
$serviceManager  : ServiceManager
$userManager  : UserManager
$workItemCommentManager  : WorkItemCommentManager

Methods

__construct()  : mixed
addClientComment()  : Result
Adds a client comment to the thread of their own work item.
countActiveForLists()  : array<int, int>
Returns counts of non-deleted work items per job in one aggregate query.
countActiveForSheets()  : array<int, int>
Returns counts of non-deleted work items per sheet in one aggregate query.
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
Guards `deleteAll()`, which bypasses hooks and is reachable over HTTP.
find()  : T|null
findAll()  : T> : list>)
findBillableForSheets()  : array<int, WorkItem>
Returns billable work items of the selected sheets: `billable = 1 AND deleted = 0` and a computed `price` other than 0 (null and 0 are skipped).
findBy()  : T|null
findClientComments()  : WorkItemComment>}
Returns the comments of a work item for its client.
findForOwnedList()  : array<int, WorkItem>
Returns the work items of a job if it belongs to user `$userId`, otherwise an empty list.
findForSheet()  : array<int, WorkItem>
Returns the work items of one work sheet, sorted as for the invoice so that admin and client see the order that appears on the document.
findForSheets()  : array<int, array<int, WorkItem>>
Returns the work items of several sheets in one query, grouped by `workSheetId` in PHP.
findOwnedItemRow()  : array{workListId: int, workSheetId: int}|null
Returns the overhead row of a work item if it belongs to a sheet of user `$userId`, otherwise `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 `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
registerEvent()  : Result
save()  : Result
setAppContext()  : $this
setAssetFormatRegistry()  : static
setCacheTagInvalidator()  : static
setInternalCallContext()  : static
setLanguageContext()  : $this
setParamsConfig()  : static
setPresenterContext()  : $this
setSettingContext()  : $this
unreadCommentCountsForClient()  : array<int, int>
Returns unread admin comment counts for the given work items, for the front end badge.
updateAllRelations()  : Result
updateColumn()  : Result
updateColumns()  : Result
Applies the read-only guard to `updateColumns()` (grid inline change and the external Api action; hooks do not run there).
updateRelations()  : Result
beforeDelete()  : Result|null
Applies the read-only guard to deletion; covers `delete(id)` and `delete([ids])`.
beforeSave()  : Result|null
beginRejectedUploads()  : array<int, array{name: string, reason: string}>
Starts a save ({@see save()}): the list of rejected items belongs to this save only; the outer one (a nested save of the same manager) is returned to the caller and restored after the save through {@see finishRejectedUploads()}.
bumpCacheTags()  : void
Bumps cache tags for the entity after commit: its class, the owned-cascade classes from metadata and the optional `additionalCacheTags()` of the manager.
bumpCacheTagsExplicit()  : void
Bumps the given classes explicitly (raw-DBAL paths with a cross-entity effect).
bumpCacheTagsForClass()  : void
Bumps cache tags by class name when there is no instance (bulk delete, `updateColumns()`, raw DBAL, tree operations).
classCacheTags()  : array<string|int, string>
Returns extra tags for a bump that has no entity, typically tree operations ({@see \App\Core\Traits\Api\Models\Managers\TreeTrait::treeTransaction()}) that rewrite `lft`/`rgt` on many rows.
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.
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_*`).
archivedListFailure()  : Result
Builds the archived job failure. It differs from the locked sheet message on purpose: the way out is different (return the job from the archive versus reopen the sheet) and the admin must see which button to use.
blockedSheetsOfItems()  : Result|null
Checks the locked sheets (or archived job) containing the given work items in one query. `null` means everything is writable, otherwise a ready failure.
bumpDeleteCacheTags()  : void
Post-commit bump after a delete. With entities it goes through {@see \App\Core\Base\BaseManager::bumpCacheTags()}, which also collects `additionalCacheTags()` (Comment → CommentThread); without them it falls back to bumping the manager class itself, the original behavior.
captureDeletedIds()  : WeakMap<object, int>
Keys the ids by the loaded entity objects, not by the input ids: an input id that loaded nothing must not shift the pairing. A weak map, so the snapshot never keeps an entity alive.
columnsInvalidateCache()  : bool
computePrice()  : string|null
Computes the price: a fixed price always wins, otherwise the rate times the actual hours.
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.
guardSheetOfSameList()  : Result|null
Guards that the move target belongs to the same job as the source.
guardSheetWritable()  : Result|null
Checks whether a sheet is writable; `null` means yes, otherwise a ready failure.
intOrNull()  : int|null
Returns `null` for a non-numeric or zero id; unifies reading ids from entities.
isAssetPathAllowed()  : bool
Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.
loadEntitiesForCacheTags()  : array<int, object>
Loads the entities being deleted only to find out their `additionalCacheTags()`. Fail-soft: what cannot be loaded is simply missing and the bump falls back to the class; it must never stop the delete.
lockedSheetFailure()  : Result
Builds the locked sheet failure, shared by `beforeSave`, `updateColumns` and deletion.
normalizeIds()  : array<int, int>
Normalizes an id set: integer, unique, positive only, reindexed to a `list`.
originWorkSheetIdOf()  : int
Returns the sheet the work item is really in, raw from the DB by work item id, not from the entity.
priceOf()  : string|null
Returns the price: manual price, otherwise rate times actual hours rounded to the scale of the `price` column.
rawInt()  : int
Returns `0` for a non-numeric value from a raw DBAL row (`fetch*` are typed `mixed`).
recomputeStoredPrices()  : void
Recomputes `price` from the stored inputs, raw over the own table, with the same calculation as saving through `save()`. It writes only rows whose price differs (the grid sends hours and rate for every row, even unchanged ones), and `update_datetime` with them, like `beforeSave()` on update.
resolveAuthorName()  : string
Returns the displayed author name: first and last name, then the username; the fallback `#id` keeps the `user_name` column (`varchar(100)`) non-empty even for an account without details.
resolveCompanyIdOfSheet()  : int
Returns the company a work item belongs to: the chain `work_sheets`, `work_lists.company_id` in one query.
resolveDefaultHourlyRate()  : string|null
Returns the initial hourly rate: the snapshot of the chosen service, otherwise the company default.
rollbackBatch()  : void
saveWithHooks()  : Result
Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.
scalarString()  : string
Converts a raw DBAL row value to string; `fetch*` are typed `mixed`.
sheetOrderOf()  : array<int, int>
Returns the sheet order map `workSheetId => order`, the basis for {@see sortForInvoice()}.
sheetOrderOfList()  : array<int, int>
Same as {@see sheetOrderOf()} for all non-deleted sheets of one job; the client list of job work items has no explicit set of sheets.
sortForInvoice()  : array<int, WorkItem>
Sorts work items stably for the invoice and the client list: `work_sheets.order`, then `work_items.order`, then `id`.
unknownListStateFailure()  : Result
Builds the failure for a raw job `state` outside the enum.
unknownSheetStateFailure()  : Result
Builds the failure for a raw sheet `state` outside the enum.
usesAssetPathTrait()  : bool

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

PRICE_INPUT_FIELDS

`updateColumns()` fields the work item price is computed from ({@see computePrice()}).

private array<string|int, mixed> PRICE_INPUT_FIELDS = ['actualHours', 'hourlyRate', 'manualPrice']

WORK_ITEMS_TABLE

Own tables of the module; raw DBAL is allowed only on them (`cs-conv-model`, layer boundaries).

private string WORK_ITEMS_TABLE = 'cms_mod_company_work_items'

WORK_LISTS_TABLE

Same table as {@see WorkListManager::WORK_LISTS_TABLE}; used by the archived job guard.

private string WORK_LISTS_TABLE = 'cms_mod_company_work_lists'

WORK_SHEETS_TABLE

Same table as {@see WorkSheetManager::WORK_SHEETS_TABLE}; the guards read the sheet state raw.

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

$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(WorkItemService $service, EntityManagerInterface $em, WorkItemCommentManager $workItemCommentManager, ServiceManager $serviceManager, CompanyManager $companyManager, UserManager $userManager, CustomerManager $invoicerCustomerManager) : mixed
Parameters
$service : WorkItemService
$em : EntityManagerInterface
$workItemCommentManager : WorkItemCommentManager
$serviceManager : ServiceManager
$companyManager : CompanyManager
$userManager : UserManager
$invoicerCustomerManager : CustomerManager

addClientComment()

Adds a client comment to the thread of their own work item.

public addClientComment(int $workItemId, int $userId, string $text[, string|null $ip = null ]) : Result

The order is binding: ownership first, then WorkItemCommentManager::addComment(), which strips the tags, validates the text and saves. The presenter handles the rate limit (it knows the IP and transport), as in ReservationManager::book().

Comment text is plain text across the project (addTextArea), printed escaped through |breaklines. On this path the tags are also stripped on write (in addComment()), so the stored value is safe however a future template prints it.

NOTE: strip_tags() is not added to the base CommentManager::beforeSave(), which would cover Blog and Discussion too: strip_tags('a < b') returns 'a ', a silent loss of text, and it would change comments that are stored verbatim and escaped on output. Narrowing it to this path is deliberate.

Parameters
$workItemId : int
$userId : int
$text : string
$ip : string|null = null

sender IP for moderation (the entity column is not null)

Return values
Result —

success data array{commentId: int|null, approved: bool}

countActiveForLists()

Returns counts of non-deleted work items per job in one aggregate query.

public countActiveForLists(array<int, int> $workListIds) : array<int, int>

A job missing in the response means zero. The key stays workListId: "My jobs" counts the work items of the whole job across its sheets, aggregated through the JOIN to sheets. Work items of a deleted sheet are not counted.

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

map workListId => count

countActiveForSheets()

Returns counts of non-deleted work items per sheet in one aggregate query.

public countActiveForSheets(array<int, int> $workSheetIds) : array<int, int>

Used by WorkSheetManager::beforeDelete() (a sheet with work items is not deleted) and by the admin screen, so the rule "what is counted" lives only here.

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

map workSheetId => count

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

Guards `deleteAll()`, which bypasses hooks and is reachable over HTTP.

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

IMPORTANT: the parent implementation extracts ids from the filter and deletes them without beforeDelete, so the guard for deleting an invoiced work item could be bypassed by sending a filter instead of ids (internalActionDeleteAll() is a normal Api action with its own RBAC key). The affected set is therefore resolved by the same filter and run through the same lock test as beforeDelete. Without a work item of a locked sheet in the selection the behavior equals the parent.

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

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

findBillableForSheets()

Returns billable work items of the selected sheets: `billable = 1 AND deleted = 0` and a computed `price` other than 0 (null and 0 are skipped).

public findBillableForSheets(array<int, int> $workSheetIds) : array<int, WorkItem>
  • The work item status does not decide: an unfinished sheet is invoiced with what is done in it; only billable and the price count.
  • The condition is != 0, not > 0: a negative manual price is a deliberately passed discount line. A zero line does not belong on a document; "free" is expressed by billable = 0.
  • It takes N sheets at once, because one invoice comes from N sheets of one job. The row order is work_sheets.order, work_items.order, id.

price is the server-side value from beforeSave() (computePrice()), so the filter needs no duplicated formula. It is compared with bccomp, not float: '0.00' and '-0.00' are zero.

Parameters
$workSheetIds : array<int, int>
Return values
array<int, WorkItem>

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

findClientComments()

Returns the comments of a work item for its client.

public findClientComments(int $workItemId, int $userId[, int|null $offset = null ][, int|null $limit = null ]) : WorkItemComment>}

Ownership is verified here, inside the manager, so the caller cannot forget it; only then WorkItemCommentManager::findForWorkItem() is called (it deliberately skips ownership, so it may be called only from here). A foreign, missing and deleted work item give the same empty result; a different response would be an oracle for the existence of foreign jobs.

Parameters
$workItemId : int
$userId : int
$offset : int|null = null
$limit : int|null = null
Return values
WorkItemComment>}

findForOwnedList()

Returns the work items of a job if it belongs to user `$userId`, otherwise an empty list.

public findForOwnedList(int $workListId, int $userId) : array<int, WorkItem>

Ownership and the selection are one query, so there is no window between the check and the read in which the job owner could change. The contract stays "work items of the whole job": the parameter is workListId (the client section knows jobs, not sheets) and the front end groups by the workSheetId each work item carries.

deleted = 0 applies at all three ends and $userId <= 0 returns empty without a query. A foreign, missing and deleted job give the same result, so the action cannot be used as an oracle for the existence of foreign jobs. Hydration is a second query by the proven ids, so an entity is never loaded before ownership holds.

Parameters
$workListId : int
$userId : int
Return values
array<int, WorkItem>

findForSheet()

Returns the work items of one work sheet, sorted as for the invoice so that admin and client see the order that appears on the document.

public findForSheet(int $workSheetId) : array<int, WorkItem>

IMPORTANT: no ownership check; the caller must prove it. The method is deliberately "dumb": it decides about one workSheetId, so it cannot be turned into a list across foreign sheets.

Parameters
$workSheetId : int
Return values
array<int, WorkItem>

findForSheets()

Returns the work items of several sheets in one query, grouped by `workSheetId` in PHP.

public findForSheets(array<int, int> $workSheetIds) : array<int, array<int, WorkItem>>

The order inside a group is the same as for the invoice (order, then id); the caller orders the groups itself. IMPORTANT: no ownership check, the caller proves it.

Parameters
$workSheetIds : array<int, int>
Return values
array<int, array<int, WorkItem>> —

map workSheetId => work items (sheets without work items have an empty array)

findOwnedItemRow()

Returns the overhead row of a work item if it belongs to a sheet of user `$userId`, otherwise `null`.

public findOwnedItemRow(int $workItemId, int $userId) : array{workListId: int, workSheetId: int}|null

IMPORTANT: this is the only ownership primitive of the client section. The owner is not on the work item or its sheet but on the job, and the job carries invoicer_customer_id while the client account sits on the customer, so the check needs the double JOIN work item, sheet, job. The entity is never trusted: its workSheetId is what the caller wants, so the ids are checked against the DB. deleted = 0 applies at all three ends; a deleted work item, sheet or job must not open the way to comments.

$userId <= 0 (no identity) and a user without customers return null without a query, so a job without a customer (invoicer_customer_id IS NULL) never matches "userId = 0".

NOTE: the key workListId stays in the response (the client section contract does not change), it is only read through the sheet now; workSheetId serves grouping.

Parameters
$workItemId : int
$userId : int
Return values
array{workListId: int, workSheetId: int}|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 `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

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

unreadCommentCountsForClient()

Returns unread admin comment counts for the given work items, for the front end badge.

public unreadCommentCountsForClient(array<int, int> $workItemIds) : array<int, int>

IMPORTANT: no ownership check; call it only for work items already verified by findForOwnedList() or findOwnedItemRow(). Over an unverified set it would become an oracle for the existence of foreign work items.

Parameters
$workItemIds : array<int, int>
Return values
array<int, int>

updateAllRelations()

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

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

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

relation property name => value set on every row

Return values
Result

updateColumn()

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

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

$property : string
$value : mixed
Return values
Result

updateColumns()

Applies the read-only guard to `updateColumns()` (grid inline change and the external Api action; hooks do not run there).

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

Both ends of a move are guarded: the sheet the work item is in and the sheet workSheetId would send it to. Without the second check a work item could be pushed into an invoiced sheet.

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

beforeDelete()

Applies the read-only guard to deletion; covers `delete(id)` and `delete([ids])`.

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

IMPORTANT: a direct Api call would otherwise delete an already invoiced work item of a closed or closing sheet; the grid button only hides the action, integrity belongs on the server.

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

beforeSave()

protected beforeSave(bool $isInsert, WorkItem &$entity[, array<string, mixed> &$meta = [] ]) : Result|null
Parameters
$isInsert : bool
$entity : WorkItem
$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>

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

archivedListFailure()

Builds the archived job failure. It differs from the locked sheet message on purpose: the way out is different (return the job from the archive versus reopen the sheet) and the admin must see which button to use.

private static archivedListFailure() : Result
Return values
Result

blockedSheetsOfItems()

Checks the locked sheets (or archived job) containing the given work items in one query. `null` means everything is writable, otherwise a ready failure.

private blockedSheetsOfItems(array<int, int> $itemIds) : Result|null

Shared by updateColumns(), where hooks do not run. The first obstacle is reported, because "invoice being issued" and "invoiced" are different situations for the admin (one waits, the other needs "Reopen sheet").

Parameters
$itemIds : array<int, int>
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>

columnsInvalidateCache()

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

computePrice()

Computes the price: a fixed price always wins, otherwise the rate times the actual hours.

private computePrice(WorkItem $entity) : string|null

IMPORTANT: the calculation uses bcmath on strings, not (float). With floats large values overflowed to INF, number_format(INF, 2) produced the string 'inf'; a strict sql_mode fails such a save with a generic database error, a non-strict one stores 0.00, i.e. free work without any message. Rounding is half-up to the column precision.

Parameters
$entity : WorkItem
Return values
string|null

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

guardSheetOfSameList()

Guards that the move target belongs to the same job as the source.

private guardSheetOfSameList(array<int, int>|array<int, int> $itemIds, int $targetWorkSheetId) : Result|null

Without it, work done for client A could be moved into a sheet of client B and invoiced to B, and the invoice shows only the work item name, so nothing hints that the hours belong to someone else. The job also carries the company and the invoicing customer (the sheet carries neither), so the same job implies the same company, client and account, and it forbids a move between two jobs of the same client too: different invoicing context, and the comments move with the work item (they bind to work_item_id), which would leak content.

Work items already in the target sheet are skipped (no move). null means ok or nothing to compare (empty input or a missing target, which guardSheetWritable() already rejected).

Parameters
$itemIds : array<int, int>|array<int, int>
$targetWorkSheetId : int
Return values
Result|null

guardSheetWritable()

Checks whether a sheet is writable; `null` means yes, otherwise a ready failure.

private guardSheetWritable(int $workSheetId) : Result|null

Two independent conditions in one query (chain work item, sheet, job):

  1. the sheet state (WorkSheetState::lockedStates(): closing/closed/invoiced),
  2. the job state: an archived job is read-only including its open sheets (first "Return from archive", then write).

The state is read raw, not through the sheet manager: the guard must see the current value (invoicing runs in another request and MySQL session), not what the identity map or query cache holds. A missing or deleted sheet is also a rejection, because a foreign key RESTRICT hangs on work_sheet_id and the result would be an unreadable SQL error.

Parameters
$workSheetId : int
Return values
Result|null

intOrNull()

Returns `null` for a non-numeric or zero id; unifies reading ids from entities.

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

isAssetPathAllowed()

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

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

loadEntitiesForCacheTags()

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

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

lockedSheetFailure()

Builds the locked sheet failure, shared by `beforeSave`, `updateColumns` and deletion.

private static lockedSheetFailure(array<int, int> $sheetIds, WorkSheetState $state) : Result
Parameters
$sheetIds : array<int, int>
$state : WorkSheetState
Return values
Result

normalizeIds()

Normalizes an id set: integer, unique, positive only, reindexed to a `list`.

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

originWorkSheetIdOf()

Returns the sheet the work item is really in, raw from the DB by work item id, not from the entity.

private originWorkSheetIdOf(int $workItemId) : int

0 means the work item is not in the DB (new or deleted row), which the guard treats as nothing to protect.

Parameters
$workItemId : int
Return values
int

priceOf()

Returns the price: manual price, otherwise rate times actual hours rounded to the scale of the `price` column.

private priceOf(string|null $manualPrice, string|null $rate, string|null $hours) : string|null
Parameters
$manualPrice : string|null
$rate : string|null
$hours : string|null
Return values
string|null

rawInt()

Returns `0` for a non-numeric value from a raw DBAL row (`fetch*` are typed `mixed`).

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

recomputeStoredPrices()

Recomputes `price` from the stored inputs, raw over the own table, with the same calculation as saving through `save()`. It writes only rows whose price differs (the grid sends hours and rate for every row, even unchanged ones), and `update_datetime` with them, like `beforeSave()` on update.

private recomputeStoredPrices(array<int, int> $ids) : void
Parameters
$ids : array<int, int>

resolveAuthorName()

Returns the displayed author name: first and last name, then the username; the fallback `#id` keeps the `user_name` column (`varchar(100)`) non-empty even for an account without details.

private resolveAuthorName(int $userId) : string
Parameters
$userId : int
Return values
string

resolveCompanyIdOfSheet()

Returns the company a work item belongs to: the chain `work_sheets`, `work_lists.company_id` in one query.

private resolveCompanyIdOfSheet(int $workSheetId) : int

Through managers it would be three hops and three queries for one number, and it would bring back the constructor dependency on WorkListManager and WorkSheetManager, i.e. the DI cycle (see WorkItemManagerFactory). Both tables belong to this module, so a raw SELECT does not break the layer boundary (cs-conv-model).

0 means the sheet does not exist or has no job (should not happen, foreign key RESTRICT).

Parameters
$workSheetId : int
Return values
int

resolveDefaultHourlyRate()

Returns the initial hourly rate: the snapshot of the chosen service, otherwise the company default.

private resolveDefaultHourlyRate(WorkItem $entity) : string|null

The admin can still override it on the entity.

Parameters
$entity : WorkItem
Return values
string|null

saveWithHooks()

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

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

scalarString()

Converts a raw DBAL row value to string; `fetch*` are typed `mixed`.

private static scalarString(mixed $value) : string
Parameters
$value : mixed
Return values
string

sheetOrderOf()

Returns the sheet order map `workSheetId => order`, the basis for {@see sortForInvoice()}.

private sheetOrderOf(array<int, int> $workSheetIds) : array<int, int>
Parameters
$workSheetIds : array<int, int>
Return values
array<int, int>

sheetOrderOfList()

Same as {@see sheetOrderOf()} for all non-deleted sheets of one job; the client list of job work items has no explicit set of sheets.

private sheetOrderOfList(int $workListId) : array<int, int>
Parameters
$workListId : int
Return values
array<int, int>

sortForInvoice()

Sorts work items stably for the invoice and the client list: `work_sheets.order`, then `work_items.order`, then `id`.

private sortForInvoice(array<int, object> $items[, array<int, int> $sheetOrder = [] ]) : array<int, WorkItem>

It sorts in PHP on purpose: order is a reserved word in the DB and sorting through filters would be a needless risk on this path; the sets have tens of rows. Sheets with the same order (an empty $sheetOrder gives all of them 0) fall back to the sheet id, so items stay grouped by sheet and the work item order decides within a sheet. The instanceof filter is not cosmetic: findAll() is typed with the generic BaseEntity and a row of another type must not reach the invoice.

Parameters
$items : array<int, object>
$sheetOrder : array<int, int> = []

map workSheetId => order

Return values
array<int, WorkItem>

unknownListStateFailure()

Builds the failure for a raw job `state` outside the enum.

private static unknownListStateFailure(int $workListId, string $raw) : Result

IMPORTANT: fail-closed; the message names the DB value instead of silently assuming Open. The column is not null, so only a corrupted row gets here.

Parameters
$workListId : int
$raw : string
Return values
Result

unknownSheetStateFailure()

Builds the failure for a raw sheet `state` outside the enum.

private static unknownSheetStateFailure(array<int, int> $sheetIds, string $raw) : Result

IMPORTANT: fail-closed. The message differs from unknownListStateFailure() on purpose, so the admin can tell whether the job row or the sheet row is corrupted.

Parameters
$sheetIds : 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