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
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_MAX_ATTEMPTS
private
int
LOCK_MAX_ATTEMPTS
= 3
LOCK_SCALE
private
int
LOCK_SCALE
= 3
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
$appContext
protected
AppContext
$appContext
$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.
$languageContext
protected
LanguageContext|null
$languageContext
= NULL
$nonInvalidatingColumns
Columns whose update does not invalidate the cache: view counters (`registerEvent()` bumps the counter on every pageview). Without this every view would flush the query cache of the whole class; counters are therefore stale up to the TTL. Override per manager by redefining the property.
protected
array<string|int, string>
$nonInvalidatingColumns
= ['views', 'viewsList', 'viewsDetail']
Covers all view counters across modules: views (Blog Article), viewsList (list
impression of Advert/Message) and viewsDetail (detail view of Advert/Message,
registerEvent() in the *DetailTraits). IMPORTANT: viewsDetail must stay in the
list, otherwise every open of an advert/post detail would invalidate all lists of the
class.
$paramsConfig
Neon parameters (`%params%`: server paths and directories), set by the decorator (`decorators.neon`) like `setAppContext()`.
protected
Config|null
$paramsConfig
= null
Write logic (asset directories) reads paths from here, not from getSettings() or the
setting context, which may be filled by the client without server paths. The property
cannot be named $config: it collides with the promoted $config in some managers.
$presenterContext
protected
PresenterContext
$presenterContext
$settingContext
protected
SettingContext
$settingContext
$totalCount
protected
int|null
$totalCount
= null
$useTransactionForDelete
protected
bool
$useTransactionForDelete
= true
$useTransactionForSave
protected
bool
$useTransactionForSave
= true
$companyManager read-only
private
CompanyManager
$companyManager
$em read-only
private
EntityManagerInterface
$em
$invoiceGenerator read-only
private
CompanyInvoiceGenerator
$invoiceGenerator
$invoiceManager read-only
private
InvoiceManager
$invoiceManager
$invoicerCustomerManager read-only
private
CustomerManager
$invoicerCustomerManager
$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
= []
$workItemManagerFactory read-only
private
WorkItemManagerFactory
$workItemManagerFactory
$workSheetManager read-only
private
WorkSheetManager
$workSheetManager
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|nulldelete()
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): ?Resultruns before the delete (validation or capturing context while the rows exist). A failed Result means rollback and return. Its data flows intoafterDeleteunder$meta['__beforeDelete'].afterDelete(array $entities, Result $result, array $meta): ?Resultruns 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 inbeforeDelete(collect their ids there). Additive or irreversible effects (credits, e-mails, invoices) do not belong here, only intocompletedDelete.completedDelete(array $entities, Result $result, array $meta): voidruns best effort after the commit, outside the transaction; an exception is only logged (the delete stays).completedAssetCleanup(array $entities, Result $result, array $meta): voidruns best effort after the commit and releases theAssetrows and files the deleted entity owned. The body is in AssetProcessingTrait; opt-in isownedAssetIds(object $entity): arrayon 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
ResultdeleteAll()
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
ResultdeleteRefusal()
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|nullfind()
public
find(int $id[, string|array<string|int, mixed> $includes = [] ]) : T|null
Parameters
- $id : int
- $includes : string|array<string|int, mixed> = []
Return values
T|nullfindAll()
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> : listentities, 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|nullfindForUser()
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: arrayfindRelatedIds()
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)
getAppContext()
public
getAppContext() : AppContext
Return values
AppContextgetDefaultSkipFields()
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 scalaruserIdhas an#[ORM\AttributeOverride]withinsertable: false, updatable: falsein the Api leaf and the column belongs to the association$user, so['user' => ['id' => $userId]]; ComcatResponsehas no$userassociation, 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>getLanguageContext()
public
getLanguageContext() : LanguageContext|null
Return values
LanguageContext|nullgetPresenterContext()
public
getPresenterContext() : PresenterContext
Return values
PresenterContextgetServiceClass()
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-stringgetSettingContext()
public
getSettingContext() : SettingContext
Return values
SettingContextgetSettings()
public
getSettings() : array<int, array<string, mixed>>
Return values
array<int, array<string, mixed>>getTotalCount()
public
getTotalCount() : int|null
Return values
int|nullinitializeAllContexts()
public
initializeAllContexts() : void
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:
- take the job lock
- read the job and all selected sheets raw, run guards (recovery branch, see recoverInterrupted())
- select billable items across the sheets
- 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 - open|closed -> closing: one conditional bulk update, affected == count(sheetIds)
(stores the original state in
state_before_closing, detaches the old invoice link) - re-check the item selection (time-of-check race); mismatch -> revert + fail
- log the intent, then
InvoiceManager::save() - save failed -> probe whether the invoice row exists -> revert to
state_before_closing - one update:
invoicer_invoice_id+ stateinvoiced+bill = 0+closed_datetime; link and state appear together or not at all - 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:
closingwith a filled id; the next call finishes it. - between 9 and 10:
invoiced; rejected withalreadyInvoiced, 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
boollockName()
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
stringpreviewInvoiceSheets()
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
Resultsave()
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
ResultsetAppContext()
public
setAppContext(AppContext $appContext) : $this
Parameters
- $appContext : AppContext
Return values
$thissetAssetFormatRegistry()
public
setAssetFormatRegistry(AssetFormatRegistry $assetFormatRegistry) : static
Parameters
- $assetFormatRegistry : AssetFormatRegistry
Return values
staticsetCacheTagInvalidator()
public
setCacheTagInvalidator(CacheTagInvalidator $cacheTagInvalidator) : static
Parameters
- $cacheTagInvalidator : CacheTagInvalidator
Return values
staticsetInternalCallContext()
public
setInternalCallContext(InternalCallContext $internalCallContext) : static
Parameters
- $internalCallContext : InternalCallContext
Return values
staticsetLanguageContext()
public
setLanguageContext(LanguageContext|null $languageContext) : $this
Parameters
- $languageContext : LanguageContext|null
Return values
$thissetParamsConfig()
public
setParamsConfig(Config $paramsConfig) : static
Parameters
- $paramsConfig : Config
Return values
staticsetPresenterContext()
public
setPresenterContext(PresenterContext $presenterContext) : $this
Parameters
- $presenterContext : PresenterContext
Return values
$thissetSettingContext()
public
setSettingContext(SettingContext $settingContext) : $this
Parameters
- $settingContext : SettingContext
Return values
$thisunarchive()
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
companychannel (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
ResultupdateColumn()
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
ResultupdateColumns()
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
ResultupdateRelations()
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
ResultacquireSourceLock()
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
boolbeforeDelete()
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|nullbeforeSave()
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|nullbeginRejectedUploads()
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
ConnectionfinishRejectedUploads()
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|nullgetService()
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
stringreadUploadedItem()
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
pathit is checked before reading
Return values
string|nullrecordRejectedUpload()
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
Return values
TbumpDeleteCacheTags()
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|nullclosingRowsOf()
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
boolcreateFollowUpSheet()
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
ResultdeleteGuard()
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|nullfailure()
private
failure(ResultCode $code, string $errorCode, string $message) : Result
Parameters
- $code : ResultCode
- $errorCode : string
- $message : string
Return values
ResultfetchListRow()
Authoritative job row (raw DBAL, see class docblock).
private
fetchListRow(int $id) : array<string, mixed>|null
Parameters
- $id : int
Return values
array<string, mixed>|nullfetchSheetRows()
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{listfailure, 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
closingsheets of the selection (null= none)
Return values
Result|nullhandleInvoiceSaveFailure()
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
Return values
ResultintOrNull()
private
intOrNull(mixed $value) : int|null
Parameters
- $value : mixed
Return values
int|nullinvoicedRowsOf()
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
boolinvoiceSuccess()
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
ResultisAssetPathAllowed()
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
boolitemIds()
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-stringlcmp()
private
lcmp(string $a, string $b) : int
Parameters
- $a : string
- $b : string
Return values
intldec()
private
ldec(string $value) : numeric-string
Parameters
- $value : string
Return values
numeric-stringldiv()
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-stringlInt()
private
lInt(mixed $value) : int
Parameters
- $value : mixed
Return values
intlmul()
private
lmul(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringln()
private
ln(string $value) : numeric-string
Parameters
- $value : string
Return values
numeric-stringloadEntitiesForCacheTags()
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
ResultlogDetachedInvoiceLinks()
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>>
lStr()
private
lStr(mixed $value) : string
Parameters
- $value : mixed
Return values
stringlStrN()
private
lStrN(mixed $value) : string|null
Parameters
- $value : mixed
Return values
string|nulllsub()
private
lsub(string $a, string $b) : numeric-string
Parameters
- $a : string
- $b : string
Return values
numeric-stringnormalizeIds()
private
normalizeIds(array<int, int> $ids) : array<int, int>
Parameters
- $ids : array<int, int>
Return values
array<int, int>now()
private
now() : string
Return values
stringrecoverInterrupted()
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:
- All selected are
closingwith the same filledinvoicer_invoice_id: the invoice provably exists, only the state remains. Finished by step 9 (idempotent, no second invoice,recovered: true). - All selected are
closingwithout an invoice link: the server cannot tell whether the invoice was created. Sheets return tostate_before_closingand 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. - Anything mixed (part
closing, part not; different invoice ids; part with link, part without): hard failclosingMixedState. 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 => rowof the whole selection
Return values
ResultrevertClosing()
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
rollbackBatch()
private
rollbackBatch() : void
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
ResultrunLockedListTransition()
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
ResultsaveWithHooks()
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
ResultsumItemPrices()
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
stringtransitionToClosing()
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
boolunknownStateFailure()
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
ResultusesAssetPathTrait()
private
static usesAssetPathTrait(string $class) : bool
Parameters
- $class : string