CoolCMS API

TimeSlotManager extends Manager
in package
uses SourceLockTransactionTrait

Company reservation calendar on an interval model; the server alone decides what is free.

The scoping unit is the calendar (Calendar), not the company: calendar_id is the authoritative row key and company_id is denormalized, derived by reconcileCalendarCompany(). Company-keyed methods wrap the calendar-keyed ones.

A day has three layers:

  • Windows: a closed_day row closes the day, else slot rows with public = 1 apply exclusively, else the continuous hours of Calendar::getOpeningHoursVo().
  • Occupied: slot rows with an active reservation and blocked rows.
  • Free = windows minus occupied, trimmed from the left by the past and the lead time.

Intervals are half-open [from, to), computed in minutes from midnight; 24:00 is the end of the day.

IMPORTANT: public = 1 is a daily override that defines the offered windows, public = 0 is a technical reservation carrier that only subtracts time. A row created by the admin must be public = 1, otherwise the first reservation would flip the day into override mode and all other windows would vanish.

IMPORTANT: the unique key (calendar_id, date, time_from) allows one row per start, so a colliding row is stored with shifted seconds (freeTimeFromKey()). The key does not protect against overlap of different starts; only the daily lock does.

IMPORTANT: admin writes and book() share one advisory lock per calendar and day (dayLockName(), the single source of the name). Without it a block could be created over a just-booked reservation and the appointment would silently vanish.

Tags
phpstan-type

TCalendarRow array{id: int|null, type: string, public: bool, from: int, to: int, note: string|null, occupied: bool, timeFromSql: string}

TStoredRow array{id: int, companyId: int, calendarId: int, date: string, type: string, public: bool, from: int, to: int, occupied: bool, timeFromSql: string}

TInterval array{from: string, to: string}

TWindow array{from: string, to: string, note: string|null}

TStart array{from: string, to: string, maxTo: string}

TDay array{ date: string, dayOfWeek: int, source: string, closed: bool, state: string, slotLengthMinutes: int, windows: list<TWindow>, busy: list<TInterval>, busyOutside: list<TInterval>, free: list<TInterval>, starts: list<TStart>, windowCount: int, freeCount: int, freeMinutes: int }

extends

Table of Contents

Constants

DATE_FORMAT  : string = 'Y-m-d'
Day format in keys and response values (ISO, sortable as a string).
DAY_LOCK_TIMEOUT  : int = 5
Seconds to wait for the daily advisory lock, shared by `book()` and admin writes so two paths over one day do not wait for different times. More than a few seconds leaves the client or admin staring at a spinner.
DAY_MINUTES  : int = 1440
Minutes per day; also the end-of-day `24:00` in minute arithmetic.
STATE_CLOSED  : string = 'closed'
Day state for the monthly UI grid.
STATE_FREE  : string = 'free'
STATE_FULL  : string = 'full'
STATE_PARTIAL  : string = 'partial'
TIME_FORMAT  : string = 'H:i'
Time format in the response; without seconds (seconds are technical disambiguation, not data).
DELETED_IDS_META  : string = '__deletedIds'
Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.
LOCK_MAX_ATTEMPTS  : int = 3
LOCK_SCALE  : int = 3
RESERVATIONS_TABLE  : string = 'cms_mod_company_reservations'
TIME_SLOTS_TABLE  : string = 'cms_mod_company_time_slots'
Own tables of the module; raw DBAL is allowed only on them (`cs-conv-model`, layer boundaries).

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).
$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
$calendarManager  : CalendarManager
$companyManager  : CompanyManager
$em  : EntityManagerInterface
$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`.
$reservationManagerFactory  : ReservationManagerFactory

Methods

__construct()  : mixed
absorbRow()  : void
Safely removes a row that a slot merge "lost" (its time is absorbed by the surviving carrier or result window).
absorbWindowsWithin()  : void
Deletes (FK-safe, {@see absorbRow()}) all offered windows of the day (`slot`, `public = 1`, without an active reservation) that lie entirely inside `[from, to)`, except `$exceptIds`. After a merge it replaces the old windows with the single result.
availability()  : TDay>}
Returns monthly availability keyed by company (backward-compatible wrapper).
availabilityForCalendar()  : TDay>}
Returns availability of a whole month for one calendar (primary path); `$month` is `YYYY-MM`.
computeDay()  : TDay
Computes windows, occupied time, free time and the offer from the rows of a day; the core of the interval model.
dayAvailability()  : TDay|null
Returns availability of one day keyed by company (backward-compatible wrapper); uses the default calendar of the company. The primary path is {@see dayAvailabilityForCalendar()}.
dayAvailabilityAuthoritative()  : TDay
Returns availability of a day from raw data; {@see ReservationManager::book()} calls it under the advisory lock (double check).
dayAvailabilityForCalendar()  : TDay|null
Returns availability of one day of a calendar; the cheap path for `book()` before the lock and for the daily schedule.
dayDetailForCalendar()  : TWindow>, segments: list}|null
Returns the day detail with reservation identities (admin only, never the front end, which has only the anonymous {@see availabilityForCalendar()} and {@see dayAvailabilityForCalendar()}).
dayLockName()  : string
Returns the name of the daily advisory lock; the single source of truth.
delete()  : Result
Deletes a calendar row under the day lock too: `beforeDelete()` asks whether a reservation hangs on the row, and without the lock one could appear between the question and the `DELETE` (`book()` recycles a free carrier, see `resolveCarrierSlot()` path 2).
deleteAll()  : Result
earliestBookable()  : DateTimeImmutable
Returns the earliest moment a client may book: now plus the lead time of the calendar.
find()  : T|null
findAll()  : T> : list>)
findBy()  : T|null
findDayRowsRaw()  : array<int, TCalendarRow>
Returns the raw (DBAL) rows of one day of one calendar, including the active-reservation flag: a single query, no identity map. Used by `book()` under the lock and by write guards.
findLockContext()  : array{calendarId: int, date: string}|null
Returns the calendar and day of a row raw; the only thing a foreign manager ({@see ReservationManager::updateByAdmin()}) needs to build the day lock name ({@see dayLockName()}). The full row shape ({@see findRowRaw()}) stays private: only this narrow pair goes out, not the internal contract of the guards.
findRangesByIds()  : array<int, array{date: string, timeFrom: string, timeTo: string, companyId: int}>
Returns date and range for the given slots in a single query ("My appointments").
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).
formatMinutes()  : string
Converts minutes to `HH:MM`; 1440 is `24:00` (end of day in the {@see OpeningHours} notation).
freeTimeFromKey()  : string|null
Returns a free `time_from` key (with seconds) for a new row starting in the given minute, see the seconds disambiguation in the class docblock. `null` means the whole minute is exhausted (60 rows with the same start; practically impossible, but it must not silently overwrite).
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
growSlot()  : Result
Grows an existing slot to a new range (both directions, unlike {@see shorten()}) and optionally promotes it to a daily override (`public = 1`, `$makePublic = true`) in one atomic write, so {@see ReservationManager::mergeSlots()} does not have to create a second, redundant row just to open a window over the newly covered time (unlike {@see openWindow()}, which always creates a new row).
hasReservationHistory()  : bool
Returns whether any reservation (even historical or canceled) hangs on the row.
initializeAllContexts()  : void
minutesFrom()  : int
Converts `H:i` / `H:i:s` to minutes from midnight; seconds of a start are rounded down.
minutesTo()  : int
Converts `H:i` / `H:i:s` to minutes from midnight; seconds of an end are rounded up, so the DB convention "end of day = 23:59:59" gives 1440 (`24:00`), not 1439.
openWindow()  : Result
Opens a new bookable window (override `slot`/`public = 1`) for a time that is not in the offer today (typically a click on "closed" in the day timeline outside existing windows).
rangeFitsFree()  : bool
Returns whether the range `[from, to)` lies entirely inside one free interval of the day.
rangeHitsAnyBusy()  : bool
Like {@see rangeHitsBusy()} but also counts `busyOutside` (occupancy outside the offered windows of the day). For admin writes that deliberately ignore the windows ("create a reservation right away" on a closed or out-of-window time) but must still reject a real collision with an existing reservation or block, wherever it lies in the day.
rangeHitsBusy()  : bool
Returns whether the range overlaps any occupied interval of the day; it separates the "occupied" message from "outside the offer", both fail but the client deserves to know which.
registerEvent()  : Result
save()  : Result
Saves a calendar row under the same day lock that {@see ReservationManager::book()} holds.
setAppContext()  : $this
setAssetFormatRegistry()  : static
setCacheTagInvalidator()  : static
setInternalCallContext()  : static
setLanguageContext()  : $this
setParamsConfig()  : static
setPresenterContext()  : $this
setSettingContext()  : $this
shorten()  : Result
Shortens the carrier of an active reservation (admin "Shorten").
split()  : Result
Splits the carrier of an active reservation at `$cutTime` (admin "Split").
sqlTime()  : string
Converts minutes to a value for a DB `TIME` column. The end of the day is stored as `23:59:59`: a `TIME` column can hold `24:00:00`, but comparing it with `time_from` of other rows and exporting it would make an odd edge case. It is read back via {@see minutesTo()}, which rounds seconds up to 1440.
updateAllRelations()  : Result
updateColumn()  : Result
updateColumns()  : Result
Rejects structural columns in partial column writes.
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
Guards deleting: a slot with an active reservation cannot be deleted.
beforeSave()  : Result|null
Write guards for a calendar row:
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
Returns the single connection for raw reads and the advisory lock ({@see SourceLockTransactionTrait}). `protected` is required by the trait (abstract `connection()`) and guarantees that `GET_LOCK` and the write run on the same MySQL session; otherwise the lock would protect nothing.
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
Returns the Tracy channel for failed `RELEASE_LOCK`; the Company module has its own log.
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).
applyGrowSlot()  : Result
Runs the grow guards and write under the day lock on the freshly read row.
applyOpenWindow()  : Result
applyShorten()  : Result
Runs the shorten guards and write under the day lock on the freshly read row.
applySplit()  : Result
Runs the split guards and write under the day lock on the freshly read row.
assignFreeTimeFromKey()  : Result|null
Applies the seconds disambiguation of the unique key (see the class docblock); the one place for all row types.
buildDays()  : array<string, TDay>
Builds the days of a range for the monthly view. Reads with two queries (calendar rows plus occupancy), never per day; otherwise the monthly view would issue 30+ queries.
buildStarts()  : array<int, TStart>
Builds suggested starts for the UI: a grid by `slotLength` inside free intervals (not by the window start: after a reservation 9:00-10:30 it makes sense to offer 10:30, not 11:00).
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.
clipRangesFrom()  : array<int, array{0: int, 1: int}>
Clips from the left by the past and lead time. An interval ending at the boundary disappears whole.
columnsInvalidateCache()  : bool
cutoffMinutes()  : int
Returns the day boundary in minutes: `0` means no clipping, `1440` means the whole day is beyond the boundary (past). Seconds are rounded up so an already elapsed minute is not offered.
day()  : TDay
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.
emptyAvailability()  : TDay>}
emptyAvailabilityForCalendar()  : TDay>}
Returns an empty availability of a calendar: the same shape as {@see emptyAvailability()} plus the key `calendarId`. `companyId` stays `0`: the calendar could not be loaded, so there is no source for the company and guessing it would publish a false value.
exportRanges()  : array<int, TInterval>
findActiveReservationIdentities()  : array<int, array{name: string, state: string, note: ?string, reservationId: int}>
Returns the identity of the active reservation (`pending`/`confirmed`) per slot in a single query for the whole day (raw DBAL on the module's own table, like {@see findDayRowsRaw()}).
findAnyReservationSlotIds()  : array<int, true>
Returns the slots that have any reservation (even canceled), because of the FK restrict on delete.
findOccupiedSlotIds()  : array<int, true>
Returns the set of slots with an active reservation (`pending|confirmed`).
findRowRaw()  : TStoredRow|null
Returns the stored state of one calendar row raw (DBAL), including the active-reservation flag.
guardActiveReservationRow()  : Result|null
Allows a row with an active reservation to change only what is unrelated to the time of the appointment (note, visibility).
guardBlocked()  : Result|null
Guards an admin block: it may take only free time.
guardClosedDay()  : Result|null
Guards `closed_day`: a day with an active reservation cannot be closed.
guardLockedDay()  : Result|null
Checks that the process holds the lock of the day it is now writing.
guardShortenableRow()  : Result|null
Guards {@see shorten()} and {@see split()}: both may touch only a `slot` row with an active reservation.
guardSlot()  : Result|null
Guards an admin override/carrier `slot`: the only thing to handle before saving is the unique key `(calendar_id, date, time_from)`. Without it a window created over the minute where a reservation carrier already sits fails with `DB_DUPLICATE_ENTRY` and closes the EntityManager.
insertOverrideWindow()  : Result
Stores one override row (`slot`/`public = 1`) through `save()`, so it passes the same guards as any other write (`guardSlot()` handles the seconds disambiguation of the unique key `(calendar_id, date, time_from)`; free time is deliberately not checked for an override, because the override is what defines the day's offer, see {@see guardSlot()}).
intersectRanges()  : array<int, array{0: int, 1: int}>
isAssetPathAllowed()  : bool
Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.
ladd()  : numeric-string
lcmp()  : int
ldec()  : numeric-string
ldiv()  : numeric-string
Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).
lInt()  : int
lmul()  : numeric-string
ln()  : numeric-string
loadEntitiesForCacheTags()  : array<int, object>
Loads the entities being deleted only to find out their `additionalCacheTags()`. Fail-soft: what cannot be loaded is simply missing and the bump falls back to the class; it must never stop the delete.
lStr()  : string
lStrN()  : string|null
lsub()  : numeric-string
materializeDefaultWindows()  : Result
Materializes a default-mode day into explicit override rows.
mergeRanges()  : array<int, array{0: int, 1: int}>
Merges overlapping and adjacent intervals into a disjoint ascending set, a precondition of the subtraction and intersection below.
normalizeEntityRows()  : array<int, TCalendarRow>
Converts entities to the same normalized shape {@see findDayRowsRaw()} returns, so both paths feed the single {@see computeDay()}.
normalizeSqlTime()  : string
Normalizes `H:i` and `H:i:s` to `HH:MM:SS` (the unique index key is compared as a string).
parseDate()  : DateTimeImmutable|null
Parses `YYYY-MM-DD` to the midnight of that day; overflowing dates (such as February 31) are null.
parseMonth()  : DateTimeImmutable|null
Parses `YYYY-MM` to the first day of the month; anything else (even "2026-13") is null.
previousTerm()  : array{date: string, oldTimeFrom: string, oldTimeTo: string}
Returns the original term of the carrier before shortening or splitting (read under the lock), for the e-mail to the client about the term change ({@see ReservationManager::shortenTerm()} and `splitTerm()`).
rangeFitsWindows()  : bool
Returns whether the range `[from, to)` lies entirely inside the union of the offered windows of the day (regardless of occupancy, unlike {@see rangeFitsFree()}, which also subtracts busy). The idempotent shortcut of {@see applyOpenWindow()} and `mergeSlots()`.
rangeHitsRanges()  : bool
reconcileCalendarCompany()  : Result|null
Reconciles `calendarId` and `companyId` on the row before the write.
rollbackBatch()  : void
saveWithHooks()  : Result
Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.
splitTime()  : array{0: int, 1: int, 2: int}
subtractRanges()  : array<int, array{0: int, 1: int}>
Subtracts one disjoint interval set from another; this is where free time is created.
sumRanges()  : int
translateDuplicate()  : Result
Translates a failed unique key `(calendar_id, date, time_from)` into a readable error.
usesAssetPathTrait()  : bool
withDayLocks()  : Result
Runs a write under a set of day locks and releases them in `finally`.
writeShrunkRange()  : Result|null
Writes `time_from`/`time_to` of one row with raw SQL; the only place where {@see shorten()}, {@see split()} and {@see growSlot()} actually write.

Constants

DATE_FORMAT

Day format in keys and response values (ISO, sortable as a string).

public string DATE_FORMAT = 'Y-m-d'

DAY_LOCK_TIMEOUT

Seconds to wait for the daily advisory lock, shared by `book()` and admin writes so two paths over one day do not wait for different times. More than a few seconds leaves the client or admin staring at a spinner.

public int DAY_LOCK_TIMEOUT = 5

DAY_MINUTES

Minutes per day; also the end-of-day `24:00` in minute arithmetic.

public int DAY_MINUTES = 1440

STATE_CLOSED

Day state for the monthly UI grid.

public string STATE_CLOSED = 'closed'

TIME_FORMAT

Time format in the response; without seconds (seconds are technical disambiguation, not data).

public string TIME_FORMAT = 'H:i'

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

RESERVATIONS_TABLE

private string RESERVATIONS_TABLE = 'cms_mod_company_reservations'

TIME_SLOTS_TABLE

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

private string TIME_SLOTS_TABLE = 'cms_mod_company_time_slots'

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.

$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

absorbRow()

Safely removes a row that a slot merge "lost" (its time is absorbed by the surviving carrier or result window).

public absorbRow(int $slotId) : void

The FK restrict (fk_company_reservation_slot) forbids deleting a row that ever had any reservation (even a long-canceled one), so such a row is only invalidated (public = 0; with occupied = false, which the caller must already ensure: an active reservation on the row has to be canceled by ReservationManager::cancelInternal() before this call, it cannot be checked from here because they are two independent tables). A row without history is deleted outright.

IMPORTANT: call it only under the day lock (same MySQL session as openWindow() and growSlot(), which the caller usually calls right after); it does not take the lock itself.

Parameters
$slotId : int

absorbWindowsWithin()

Deletes (FK-safe, {@see absorbRow()}) all offered windows of the day (`slot`, `public = 1`, without an active reservation) that lie entirely inside `[from, to)`, except `$exceptIds`. After a merge it replaces the old windows with the single result.

public absorbWindowsWithin(int $calendarId, string $date, int $from, int $to[, array<int, int> $exceptIds = [] ]) : void

Rows are read fresh, so windows materialized from default working hours during this operation are included.

IMPORTANT: call it only under the day lock (it does not take it) and only after the result window exists; otherwise a day without an override would fall back to working hours and the offer would silently widen.

Parameters
$calendarId : int
$date : string
$from : int
$to : int
$exceptIds : array<int, int> = []

availability()

Returns monthly availability keyed by company (backward-compatible wrapper).

public availability(int $companyId, string $month) : TDay>}

Callers unaware of calendars (Api\TimeSlotPresenter, Admin\TimeSlotPresenter, front bridge) use it unchanged: the default calendar of the company is substituted and the response gets the key companyId its contract carries. The primary path is availabilityForCalendar().

A company without a usable calendar yields an empty availability (no exception), the same silent emptiness as for a missing or inactive company.

Parameters
$companyId : int
$month : string
Return values
TDay>}

availabilityForCalendar()

Returns availability of a whole month for one calendar (primary path); `$month` is `YYYY-MM`.

public availabilityForCalendar(int $calendarId, string $month) : TDay>}

Working hours, offer step and lead time come from the calendar; the company contributes only bookingEnabled (and its active/deleted state as a guard), because reservations are switched on per company, not per calendar.

bookingEnabled = false does not produce an empty response: days are always computed and the caller reads the flag. Working hours are public anyway, while an empty response could not be told from "no opening hours this month" and the admin day overview would see nothing.

Parameters
$calendarId : int
$month : string
Return values
TDay>}

computeDay()

Computes windows, occupied time, free time and the offer from the rows of a day; the core of the interval model.

public computeDay(Calendar $calendar, string $date, array<int, TCalendarRow> $rows[, DateTimeImmutable|null $earliest = null ]) : TDay

Shared by the monthly overview, book() under the lock and the block guard so the reading of "what is free" cannot diverge between paths.

$earliest is the limit before which a client may not book (past plus lead time); null means no trimming, which the admin block guard uses because an admin may block time inside the lead time too.

The calendar (not the company) is the source of working hours and offer step; the logic of windows/free/busy/state is a pure function over the data.

Parameters
$calendar : Calendar
$date : string
$rows : array<int, TCalendarRow>
$earliest : DateTimeImmutable|null = null
Return values
TDay

dayAvailability()

Returns availability of one day keyed by company (backward-compatible wrapper); uses the default calendar of the company. The primary path is {@see dayAvailabilityForCalendar()}.

public dayAvailability(int $companyId, string $date) : TDay|null
Parameters
$companyId : int
$date : string
Return values
TDay|null —

null when the company has no usable calendar, the calendar does not exist or is inactive, or the date is invalid

dayAvailabilityAuthoritative()

Returns availability of a day from raw data; {@see ReservationManager::book()} calls it under the advisory lock (double check).

public dayAvailabilityAuthoritative(Calendar $calendar, string $date[, array<int, TCalendarRow> $rows = null ]) : TDay

Doctrine is bypassed on purpose: the identity map may hold a row loaded a second ago in another context and GET_LOCK protects the write, not data freshness. The calendar is passed as an object because working hours do not change during a race of two reservations. The parameter is the calendar (not the company) and $rows are calendar-scoped; the only caller (ReservationManager::createReservation()) already resolved the calendar before taking the lock.

Parameters
$calendar : Calendar
$date : string
$rows : array<int, TCalendarRow> = null

preloaded rows of the day (to avoid a second read); null = load them itself

Return values
TDay

dayAvailabilityForCalendar()

Returns availability of one day of a calendar; the cheap path for `book()` before the lock and for the daily schedule.

public dayAvailabilityForCalendar(int $calendarId, string $date) : TDay|null

Reads through Doctrine (the identity map may lag by a second), so it suits only preliminary rejection. The authoritative decision under the lock is made by dayAvailabilityAuthoritative().

Parameters
$calendarId : int
$date : string
Return values
TDay|null —

null when the calendar does not exist or is inactive, or the date is invalid

dayDetailForCalendar()

Returns the day detail with reservation identities (admin only, never the front end, which has only the anonymous {@see availabilityForCalendar()} and {@see dayAvailabilityForCalendar()}).

public dayDetailForCalendar(int $calendarId, string $date) : TWindow>, segments: list}|null

Returns the day windows by the same logic as computeDay() (override/default, no lead-time trim: the admin manages the whole day, including the part the lead time would reject for a client) plus a list of occupied segments one by one (reservations and blocks), with identity for reservations (name, client note, state).

Free gaps between segments are not computed here: that is generic interval arithmetic, not the day-resolution logic, and belongs to the caller (admin presenter).

IMPORTANT: segments are always built from the raw day rows, regardless of $day['closed'] or empty $day['windows']. A day closed by a closed_day row can also carry a blocked row (guardClosedDay() checks only occupied), and narrowing working hours after a reservation leaves it outside the recomputed windows. In both cases the admin must see the identity, otherwise this admin-only endpoint would deny its own purpose.

Parameters
$calendarId : int
$date : string
Return values
TWindow>, segments: list}|null —

null when the calendar does not exist, is inactive or deleted, or the date is invalid

dayLockName()

Returns the name of the daily advisory lock; the single source of truth.

public static dayLockName(int $calendarId, string $date) : string

Keyed by day, not by window: overlapping ranges have different starts (10:00-11:00 and 10:30-11:30), so a lock with the time in the key would not serialize them. The name must be identical for both write paths, the client ReservationManager::book() and the admin save()/delete(); two different names over one day mean no serialization. Hence a method here instead of a constant, and ReservationManager calls it.

IMPORTANT: keyed by calendar, not company (format company_calendar_slot_{calendarId}_{date}). Coarsening it back to the company would serialize two independent resources (two employees) for no reason, and keeping the old company-keyed form next to the new one would create two different locks over one day, i.e. no serialization. The old form must not remain on any path, not even temporarily.

MariaDB allows lock names up to 192 characters; this name always fits.

Parameters
$calendarId : int
$date : string
Return values
string

delete()

Deletes a calendar row under the day lock too: `beforeDelete()` asks whether a reservation hangs on the row, and without the lock one could appear between the question and the `DELETE` (`book()` recycles a free carrier, see `resolveCarrierSlot()` path 2).

public delete(int|array<int, int> $id[, array<string, mixed> $meta = [] ]) : Result

The day of every deleted row is locked (grid bulk delete passes an array of ids); duplicates are merged and the order is deterministic, see withDayLocks().

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

deleteAll()

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

earliestBookable()

Returns the earliest moment a client may book: now plus the lead time of the calendar.

public earliestBookable(Calendar $calendar) : DateTimeImmutable
Parameters
$calendar : Calendar
Return values
DateTimeImmutable

find()

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

findAll()

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

entities, or raw rows when $columns is set

findBy()

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

findDayRowsRaw()

Returns the raw (DBAL) rows of one day of one calendar, including the active-reservation flag: a single query, no identity map. Used by `book()` under the lock and by write guards.

public findDayRowsRaw(int $calendarId, string $date) : array<int, TCalendarRow>

The scope is calendar_id, not company_id: two calendars of one company are independent resources and must not subtract free time from each other nor collide on the unique key.

Parameters
$calendarId : int
$date : string
Return values
array<int, TCalendarRow>

findLockContext()

Returns the calendar and day of a row raw; the only thing a foreign manager ({@see ReservationManager::updateByAdmin()}) needs to build the day lock name ({@see dayLockName()}). The full row shape ({@see findRowRaw()}) stays private: only this narrow pair goes out, not the internal contract of the guards.

public findLockContext(int $slotId) : array{calendarId: int, date: string}|null
Parameters
$slotId : int
Return values
array{calendarId: int, date: string}|null —

null when the row does not exist

findRangesByIds()

Returns date and range for the given slots in a single query ("My appointments").

public findRangesByIds(array<int, int> $slotIds) : array<int, array{date: string, timeFrom: string, timeTo: string, companyId: int}>

Raw DBAL is fine on the module's own table (cs-conv-model); a reservation carries only the scalar time_slot_id and has no Doctrine association to load the slot through includes.

IMPORTANT: it contains neither identities nor occupancy, only the translation time_slot_id to when. The caller has already proved the right to this slot (own reservation); the public calendar has its own path (availability()) that never exposes identities.

Parameters
$slotIds : array<int, int>
Return values
array<int, array{date: string, timeFrom: string, timeTo: string, companyId: int}>

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)

formatMinutes()

Converts minutes to `HH:MM`; 1440 is `24:00` (end of day in the {@see OpeningHours} notation).

public static formatMinutes(int $minutes) : string
Parameters
$minutes : int
Return values
string

freeTimeFromKey()

Returns a free `time_from` key (with seconds) for a new row starting in the given minute, see the seconds disambiguation in the class docblock. `null` means the whole minute is exhausted (60 rows with the same start; practically impossible, but it must not silently overwrite).

public freeTimeFromKey(array<int, TCalendarRow> $rows, int $minutes) : string|null
Parameters
$rows : array<int, TCalendarRow>
$minutes : int
Return values
string|null

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

growSlot()

Grows an existing slot to a new range (both directions, unlike {@see shorten()}) and optionally promotes it to a daily override (`public = 1`, `$makePublic = true`) in one atomic write, so {@see ReservationManager::mergeSlots()} does not have to create a second, redundant row just to open a window over the newly covered time (unlike {@see openWindow()}, which always creates a new row).

public growSlot(int $slotId, string $newTimeFrom, string $newTimeTo[, bool|null $makePublic = null ]) : Result

IMPORTANT: same trap as openWindow(). When a day goes from default mode to override for the first time ($makePublic = true on a not yet override row), all current default windows of the day must be materialized first (materializeDefaultWindows()); otherwise this single override would silently erase the rest of the day's offer.

Parameters
$slotId : int
$newTimeFrom : string

H:i / H:i:s

$newTimeTo : string

H:i / H:i:s, exclusive end

$makePublic : bool|null = null

null keeps the current public value

Return values
Result —

success data array{timeSlotId: int, timeFrom: string, timeTo: string, public: bool}

hasReservationHistory()

Returns whether any reservation (even historical or canceled) hangs on the row.

public hasReservationHistory(int $slotId) : bool

ReservationManager::mergeSlots() decides with it between silently deleting the losing piece (absorbRow()) and an FK-safe invalidation. Public thin wrapper over findAnyReservationSlotIds(), which stays private (its query over RESERVATIONS_TABLE belongs to this manager only).

Parameters
$slotId : int
Return values
bool

minutesFrom()

Converts `H:i` / `H:i:s` to minutes from midnight; seconds of a start are rounded down.

public static minutesFrom(string $time) : int
Parameters
$time : string
Return values
int

minutesTo()

Converts `H:i` / `H:i:s` to minutes from midnight; seconds of an end are rounded up, so the DB convention "end of day = 23:59:59" gives 1440 (`24:00`), not 1439.

public static minutesTo(string $time) : int
Parameters
$time : string
Return values
int

openWindow()

Opens a new bookable window (override `slot`/`public = 1`) for a time that is not in the offer today (typically a click on "closed" in the day timeline outside existing windows).

public openWindow(int $calendarId, string $date, string $timeFrom, string $timeTo[, bool $replaceInside = false ]) : Result

Unlike ReservationManager::bookByAdmin() with enforceWindow = false (a direct reservation, the window is not extended), this method adds the window permanently, so it is offered to future reservations too (front end and further admin writes).

IMPORTANT: a day in default mode (no override yet) is materialized first: all its current default windows are stored as explicit override rows and only then is the new one added. Without this step adding a single window would flip the day into override mode (computeDay(): an override excludes the default) and all other default windows would silently vanish from the offer. The same class of bug as in split(), one level higher: it would hit every call of this method on a default day.

Parameters
$calendarId : int
$date : string
$timeFrom : string
$timeTo : string
$replaceInside : bool = false
Return values
Result

rangeFitsFree()

Returns whether the range `[from, to)` lies entirely inside one free interval of the day.

public rangeFitsFree(TDay $day, int $from, int $to) : bool
Parameters
$day : TDay
$from : int
$to : int
Return values
bool

rangeHitsAnyBusy()

Like {@see rangeHitsBusy()} but also counts `busyOutside` (occupancy outside the offered windows of the day). For admin writes that deliberately ignore the windows ("create a reservation right away" on a closed or out-of-window time) but must still reject a real collision with an existing reservation or block, wherever it lies in the day.

public rangeHitsAnyBusy(TDay $day, int $from, int $to) : bool
Parameters
$day : TDay
$from : int
$to : int
Return values
bool

rangeHitsBusy()

Returns whether the range overlaps any occupied interval of the day; it separates the "occupied" message from "outside the offer", both fail but the client deserves to know which.

public rangeHitsBusy(TDay $day, int $from, int $to) : bool
Parameters
$day : TDay
$from : int
$to : int
Return values
bool

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

Saves a calendar row under the same day lock that {@see ReservationManager::book()} holds.

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

The guards in beforeSave() run only inside parent::save() (inside the SaveTrait transaction), so alone they are a check-then-act race: without the lock a foreign booking fits between "read the day state" and "write", and a block or closed_day would overrun a just-created reservation. The lock is therefore taken before parent::save() and released in finally.

When a row moves (date or calendarId changes), both days are locked: the range is freed on the old day and occupied on the new one. The old day is read before the lock (otherwise there is nothing to lock); this only picks the keys, the guards re-read the authoritative state under the lock.

IMPORTANT: reconcileCalendarCompany() runs first, before the lock names are computed. It must not move into beforeSave(), which runs inside parent::save(), i.e. already under the lock; the lock is computed from calendarId, so a legacy write (only companyId) would lock calendar_0 and protect nothing.

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

setParamsConfig()

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

shorten()

Shortens the carrier of an active reservation (admin "Shorten").

public shorten(int $slotId, string $newTimeFrom, string $newTimeTo) : Result

The admin moves the start and/or end inward within the original range. Growing is forbidden: an extension could newly touch a neighboring occupied or blocked interval, which would need validation against the whole day; the admin cancels and re-creates the appointment instead.

IMPORTANT: it bypasses beforeSave() and guardActiveReservationRow() on purpose. That guard forbids any time change on an occupied row because a generic write would silently move the client's appointment. This method is the only allowed exception: an explicit, narrow, admin-only action behind its own RBAC key (:Api:Company:TimeSlot:shorten) with stricter guards of its own. It changes only time_from/time_to (never day, calendar, company or type), only inward and only on a slot row with an active reservation.

IMPORTANT: the caller sends only $slotId and target times, no "expected old range" that could diverge from reality. The row is re-read raw under the day lock, and the calendar and day for the lock name come from the stored row, not from parameters; this closes the check-then-act race.

NOTE: when the shortened row is a daily override (public = 1), the offered window of the day shrinks with it and the freed end is no longer offered. To keep offering the rest, use split() (it restores the freed part as a window) or create the window manually.

Parameters
$slotId : int
$newTimeFrom : string

H:i / H:i:s

$newTimeTo : string

H:i / H:i:s, exclusive end

Return values
Result —

success data array{timeSlotId: int, timeFrom: string, timeTo: string, date: string, oldTimeFrom: string, oldTimeTo: string}

split()

Splits the carrier of an active reservation at `$cutTime` (admin "Split").

public split(int $slotId, string $cutTime) : Result

The reservation stays on the first part ([from, cut)) and the second part ([cut, to)) is freed. This is deterministic, needs no further admin input and matches the typical reason ("the client comes for half an hour, free the rest").

IMPORTANT: the freed part is created as a new row only when the split row is a daily override (public = 1).

  • A split row that is a carrier (public = 0) frees [cut, to) by itself when it is shortened (free = windows minus occupied), so no row is needed. Creating a public = 1 row there would flip a day without overrides into override mode and all other windows of the day would vanish (the calendar would close itself).
  • A split override row shrinks the offered window too, so [cut, to) would stop being offered. A new public = 1 row restores the offer and cannot change the day mode, because the split row already puts the day into override mode.

The freed part is created through the standard save() (not a raw insert), so it passes reconcileCalendarCompany(), the guards and the seconds disambiguation of the unique key (assignFreeTimeFromKey()). Nested acquisition of the same day lock is safe: GET_LOCK is reentrant within one MySQL session (see the class docblock).

Parameters
$slotId : int
$cutTime : string

H:i / H:i:s; must lie inside the range (not on its boundary)

Return values
Result —

success data array{timeSlotId: int, timeFrom: string, timeTo: string, freedSlotCreated: bool, freedSlotId: int|null, freedFrom: string, freedTo: string, date: string, oldTimeFrom: string, oldTimeTo: string}

sqlTime()

Converts minutes to a value for a DB `TIME` column. The end of the day is stored as `23:59:59`: a `TIME` column can hold `24:00:00`, but comparing it with `time_from` of other rows and exporting it would make an odd edge case. It is read back via {@see minutesTo()}, which rounds seconds up to 1440.

public static sqlTime(int $minutes) : string
Parameters
$minutes : int
Return values
string

updateAllRelations()

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

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

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

relation property name => value set on every row

Return values
Result

updateColumn()

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

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

$property : string
$value : mixed
Return values
Result

updateColumns()

Rejects structural columns in partial column writes.

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

updateColumns() bypasses save() completely (SaveTrait::updateColumns() goes straight to service), so without this override a rewrite of calendarId, companyId, date, timeFrom, timeTo or type would skip reconcileCalendarCompany(), the day lock and guardActiveReservationRow(): a row with an active reservation could be moved to another calendar or day silently and unchecked.

Structural columns must go through save(), where reconcile, lock and guards run. Only note and public stay allowed, which is what the admin UI (rename/hide) needs.

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

updateRelations()

public updateRelations(int|array<string|int, mixed> $entityId, string $property, array<string|int, object> $relationEntities) : Result
Parameters
$entityId : int|array<string|int, mixed>

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

$property : string
$relationEntities : array<string|int, object>

relation entities with updated values

Return values
Result

acquireSourceLock()

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

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

beforeDelete()

Guards deleting: a slot with an active reservation cannot be deleted.

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

Blocks are deleted freely (nothing hangs on them); they reach the second branch only because it never matches them.

The second branch (a row with inactive history) is a conscious addition: the FK fk_company_reservation_slot is ON DELETE RESTRICT, so even a canceled reservation blocks the delete hard at the DB level. Without this branch the admin would get an SQL error and a closed EntityManager instead of a message.

IMPORTANT: bulk deleteAll() (by filter) does not run the hooks, so the admin grid must delete through delete(); otherwise this guard does not apply and only the FK catches it.

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

beforeSave()

Write guards for a calendar row:

protected beforeSave(bool $isInsert, TimeSlot &$entity[, array<string, mixed> &$meta = [] ]) : Result|null
  • closed_day cannot be created on a day with an active reservation. The admin has to cancel the reservations first (clients get an e-mail) and only then close the day; the opposite order would leave a client with a confirmed appointment that does not exist in the calendar.
  • blocked may be saved only when the whole range lies in free time; an overlap with an active reservation or another block is VALIDATION_FAILED. A duplicate block makes no sense, and an "over-blocked" reservation would silently vanish from the calendar while the client still expects it.
  • A row with an active reservation can be neither retyped nor moved (guardActiveReservationRow()). Without it, switching a recycled public = 1 row (carrier of a foreign reservation, resolveCarrierSlot() path 2) to closed_day or blocked would pass, because the guards below exclude the row's own id, and the appointment would vanish from the calendar while the client still expects it.
  • slot (and closed_day) get the seconds disambiguation of the unique key like blocks, otherwise an admin row over the minute of a carrier fails on the unique key and closes the EntityManager.

Override rows (slot, public = 1) are deliberately not checked otherwise: they do not close the day, they only change the offer, and existing reservations stay on their carrier.

NOTE: the whole beforeSave() runs under the day lock (see save()), so "read the day state, decide, write" is atomic against book(). $entity->getCalendarId() is already valid here, because save() runs reconcileCalendarCompany() before parent::save(), so the guards may read the day by calendar without further checks.

Parameters
$isInsert : bool
$entity : TimeSlot
$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()

Returns the single connection for raw reads and the advisory lock ({@see SourceLockTransactionTrait}). `protected` is required by the trait (abstract `connection()`) and guarantees that `GET_LOCK` and the write run on the same MySQL session; otherwise the lock would protect nothing.

protected connection() : Connection
Return values
Connection

finishRejectedUploads()

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

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

list from beginRejectedUploads()

getDeletedId()

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

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

getService()

Returns the service backing the manager traits.

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

lockLogChannel()

Returns the Tracy channel for failed `RELEASE_LOCK`; the Company module has its own log.

protected lockLogChannel() : string
Return values
string

readUploadedItem()

Reads the content of an uploaded item (`{name, data}` base64 or `{name, path}` from the dropzone) through the single reader {@see UploadedItem}.

protected readUploadedItem(array<string|int, mixed> $item[, bool $strictBase64 = true ][, int|null $maxBytes = null ]) : string|null

Call it right before processing and release the result before the next item, so that files never meet in memory. A path is accepted only during an internal bridge call ($internalCallContext) and only inside the dropzone root. A rejected path is logged to the auth channel and null is returned; callers treat it like unreadable data (skip the item or refuse the save).

Parameters
$item : array<string|int, mixed>
$strictBase64 : bool = true

false for callers that decode loosely (base64_decode($x))

$maxBytes : int|null = null

size limit; for path it is checked before reading

Return values
string|null

recordRejectedUpload()

Records a new uploaded item that the save skipped ({@see UploadRejection} `REASON_*`).

protected recordRejectedUpload(mixed $name, string $reason) : void

Called by the places that drop an item (processOrderedAssets(), advert attachments, product set gallery).

Parameters
$name : mixed
$reason : string

releaseSourceLock()

Releases the advisory lock.

protected releaseSourceLock(string $name) : void

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

Parameters
$name : string

runInSourceTransaction()

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

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

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

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

applyGrowSlot()

Runs the grow guards and write under the day lock on the freshly read row.

private applyGrowSlot(TStoredRow $fresh, string $newTimeFrom, string $newTimeTo, bool|null $makePublic) : Result
Parameters
$fresh : TStoredRow
$newTimeFrom : string
$newTimeTo : string
$makePublic : bool|null
Return values
Result

applyOpenWindow()

private applyOpenWindow(Calendar $calendar, int $calendarId, string $date, string $timeFrom, string $timeTo[, bool $replaceInside = false ]) : Result
Parameters
$calendar : Calendar
$calendarId : int
$date : string
$timeFrom : string
$timeTo : string
$replaceInside : bool = false
Return values
Result

applyShorten()

Runs the shorten guards and write under the day lock on the freshly read row.

private applyShorten(TStoredRow $fresh, string $newTimeFrom, string $newTimeTo) : Result
Parameters
$fresh : TStoredRow
$newTimeFrom : string
$newTimeTo : string
Return values
Result

applySplit()

Runs the split guards and write under the day lock on the freshly read row.

private applySplit(TStoredRow $fresh, string $cutTime) : Result
Parameters
$fresh : TStoredRow
$cutTime : string
Return values
Result

assignFreeTimeFromKey()

Applies the seconds disambiguation of the unique key (see the class docblock); the one place for all row types.

private assignFreeTimeFromKey(TimeSlot &$entity, array<int, TCalendarRow> $rows) : Result|null

The key is recomputed only when someone else really holds it in that minute; otherwise the value is left alone so a row's seconds are not shifted on every save (and so ReservationManager can choose the key itself and get it unchanged).

Parameters
$entity : TimeSlot
$rows : array<int, TCalendarRow>

all rows of the day (including the entity's own)

Return values
Result|null

buildDays()

Builds the days of a range for the monthly view. Reads with two queries (calendar rows plus occupancy), never per day; otherwise the monthly view would issue 30+ queries.

private buildDays(Calendar $calendar, DateTimeImmutable $from, DateTimeImmutable $to) : array<string, TDay>
Parameters
$calendar : Calendar
$from : DateTimeImmutable
$to : DateTimeImmutable
Return values
array<string, TDay>

buildStarts()

Builds suggested starts for the UI: a grid by `slotLength` inside free intervals (not by the window start: after a reservation 9:00-10:30 it makes sense to offer 10:30, not 11:00).

private buildStarts(array<int, array{0: int, 1: int}> $free, int $slotLength) : array<int, TStart>

Only starts where a whole step fits are offered; a shorter remainder (11:30-12:00 at step 60) does not appear in the offer but can still be booked: free is authoritative, starts is convenience. maxTo says how far the end chosen by the user may reach.

Parameters
$free : array<int, array{0: int, 1: int}>
$slotLength : int
Return values
array<int, TStart>

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>

clipRangesFrom()

Clips from the left by the past and lead time. An interval ending at the boundary disappears whole.

private clipRangesFrom(array<int, array{0: int, 1: int}> $ranges, int $cutoff) : array<int, array{0: int, 1: int}>
Parameters
$ranges : array<int, array{0: int, 1: int}>
$cutoff : int
Return values
array<int, array{0: int, 1: int}>

columnsInvalidateCache()

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

cutoffMinutes()

Returns the day boundary in minutes: `0` means no clipping, `1440` means the whole day is beyond the boundary (past). Seconds are rounded up so an already elapsed minute is not offered.

private cutoffMinutes(string $date, DateTimeImmutable $earliest) : int
Parameters
$date : string
$earliest : DateTimeImmutable
Return values
int

day()

private day(string $date, int $dayOfWeek, string $source, bool $closed, string $state, int $slotLength, array<int, TWindow> $windows, array<int, array{0: int, 1: int}> $busy, array<int, array{0: int, 1: int}> $busyOutside, array<int, array{0: int, 1: int}> $free) : TDay
Parameters
$date : string
$dayOfWeek : int
$source : string
$closed : bool
$state : string
$slotLength : int
$windows : array<int, TWindow>
$busy : array<int, array{0: int, 1: int}>
$busyOutside : array<int, array{0: int, 1: int}>
$free : array<int, array{0: int, 1: int}>
Return values
TDay

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

emptyAvailability()

private emptyAvailability(int $companyId, string $month) : TDay>}
Parameters
$companyId : int
$month : string
Return values
TDay>}

emptyAvailabilityForCalendar()

Returns an empty availability of a calendar: the same shape as {@see emptyAvailability()} plus the key `calendarId`. `companyId` stays `0`: the calendar could not be loaded, so there is no source for the company and guessing it would publish a false value.

private emptyAvailabilityForCalendar(int $calendarId, string $month) : TDay>}
Parameters
$calendarId : int
$month : string
Return values
TDay>}

exportRanges()

private exportRanges(array<int, array{0: int, 1: int}> $ranges) : array<int, TInterval>
Parameters
$ranges : array<int, array{0: int, 1: int}>
Return values
array<int, TInterval>

findActiveReservationIdentities()

Returns the identity of the active reservation (`pending`/`confirmed`) per slot in a single query for the whole day (raw DBAL on the module's own table, like {@see findDayRowsRaw()}).

private findActiveReservationIdentities(array<int, int> $slotIds) : array<int, array{name: string, state: string, note: ?string, reservationId: int}>
Parameters
$slotIds : array<int, int>
Return values
array<int, array{name: string, state: string, note: ?string, reservationId: int}>

findAnyReservationSlotIds()

Returns the slots that have any reservation (even canceled), because of the FK restrict on delete.

private findAnyReservationSlotIds(array<int, int> $slotIds) : array<int, true>
Parameters
$slotIds : array<int, int>
Return values
array<int, true>

findOccupiedSlotIds()

Returns the set of slots with an active reservation (`pending|confirmed`).

private findOccupiedSlotIds(array<int, int> $slotIds) : array<int, true>

NOTE: deleted is deliberately not handled. The DB backstop active_flag counts only the state, so if PHP ignored a soft-deleted reservation it would report the slot as free and the INSERT would then fail on the unique key. A slot is freed only by a transition to cancelled.

Parameters
$slotIds : array<int, int>
Return values
array<int, true>

findRowRaw()

Returns the stored state of one calendar row raw (DBAL), including the active-reservation flag.

private findRowRaw(int $id) : TStoredRow|null

Doctrine is bypassed on purpose (same reason as findDayRowsRaw()): the entity at hand already carries the new values and the identity map may hold an old object, so "what actually changes" and "does a reservation hang on it" are known only from a fresh query. Used by the write guards and by picking the days to lock in save()/delete().

Parameters
$id : int
Return values
TStoredRow|null —

null when the row does not exist

guardActiveReservationRow()

Allows a row with an active reservation to change only what is unrelated to the time of the appointment (note, visibility).

private guardActiveReservationRow(TimeSlot $entity, TStoredRow|null $stored) : Result|null

Rejected changes:

  • Retyping (slot to closed_day/blocked) on a recycled public = 1 window that became a carrier of a foreign reservation via book() path 2. The guards below exclude the row's own id, so the retyping would pass and the reservation would vanish from the calendar without any e-mail to the client.
  • Changing date/timeFrom/timeTo/companyId on a carrier: the UPDATE would silently move the client's appointment (with companyId straight to another company).
  • Changing calendarId: the same class of bug one level lower. Moving a carrier to another calendar (say, of another employee) would remove the appointment from the original calendar and create it in the target one over foreign working hours, without the client or the operator of the original calendar knowing. calendarId is the main compared value; companyId is secondary (derived from the calendar after reconcile).

Times are compared in minutes: the seconds disambiguation of the unique key (13:00:01) is not a change of the appointment, only a technique, and <input type=time> does not return it from the form anyway.

Parameters
$entity : TimeSlot
$stored : TStoredRow|null

stored state of the row (null on INSERT)

Return values
Result|null

guardBlocked()

Guards an admin block: it may take only free time.

private guardBlocked(TimeSlot &$entity, TStoredRow|null $stored) : Result|null

Computed by the same computeDay() as public availability, but without the past and lead time trim (an admin blocks "in an hour" too). It finishes with the seconds disambiguation of the unique key so a block can start where a window or a spent carrier already starts.

IMPORTANT: the own row is left out of the free-time computation only when it already is a block (editing the note/time of an existing block; otherwise it would collide with itself). When another row type is being switched to a block, the day is computed with that row included. The result is more conservative (an override window that disappears with the retyping is still counted), which is the right side to err on for occupancy.

Parameters
$entity : TimeSlot
$stored : TStoredRow|null
Return values
Result|null

guardClosedDay()

Guards `closed_day`: a day with an active reservation cannot be closed.

private guardClosedDay(TimeSlot &$entity, TStoredRow|null $stored) : Result|null

Reads raw (findDayRowsRaw()) and under the day lock, so occupancy is fresh and cannot change between the check and the write.

Parameters
$entity : TimeSlot
$stored : TStoredRow|null
Return values
Result|null

guardLockedDay()

Checks that the process holds the lock of the day it is now writing.

private guardLockedDay(TStoredRow $fresh, int $lockedCalendarId, string $lockedDate) : Result|null

The day lock name must be chosen before acquiring it, so it is built from a row read outside the lock (shorten()/split(), same pattern as save() and delete()). At that moment the row may not have been occupied yet, and a foreign save() may legally move an unoccupied row to another calendar or day (guardActiveReservationRow() brakes only rows with an active reservation). If such a move (and a following foreign reservation) happened between the preliminary read and acquiring the lock, the time change would be written on a day this process does not hold, in parallel with a foreign book() over the same day, which is what the lock prevents.

A mismatch is therefore a hard rejection, not a silent write. It is temporary (DB_LOCKED): repeating the action computes the lock name from the new location and passes.

Parameters
$fresh : TStoredRow
$lockedCalendarId : int
$lockedDate : string
Return values
Result|null

guardShortenableRow()

Guards {@see shorten()} and {@see split()}: both may touch only a `slot` row with an active reservation.

private guardShortenableRow(TStoredRow $fresh, string $action) : Result|null

A block (blocked) and a closed day (closed_day) must not pass: they have no client whose appointment would be shortened, and their time changes through plain save() (guards apply in full there). A free slot row without a reservation neither: it is a regular override edited via the form; this path exists only because beforeSave() locks an occupied row, so without occupancy it has nothing to bypass.

Parameters
$fresh : TStoredRow
$action : string
Return values
Result|null

guardSlot()

Guards an admin override/carrier `slot`: the only thing to handle before saving is the unique key `(calendar_id, date, time_from)`. Without it a window created over the minute where a reservation carrier already sits fails with `DB_DUPLICATE_ENTRY` and closes the EntityManager.

private guardSlot(TimeSlot &$entity) : Result|null

Free time is deliberately not checked: an override window may lie anywhere, since it is what defines the day's offer.

Parameters
$entity : TimeSlot
Return values
Result|null

insertOverrideWindow()

Stores one override row (`slot`/`public = 1`) through `save()`, so it passes the same guards as any other write (`guardSlot()` handles the seconds disambiguation of the unique key `(calendar_id, date, time_from)`; free time is deliberately not checked for an override, because the override is what defines the day's offer, see {@see guardSlot()}).

private insertOverrideWindow(int $companyId, int $calendarId, string $date, string $timeFrom, string $timeTo) : Result

The reentrant GET_LOCK (inside the caller's withDayLocks()) is safe: the MySQL session holds the lock until released the same number of times (like split()).

Parameters
$companyId : int
$calendarId : int
$date : string
$timeFrom : string
$timeTo : string
Return values
Result

intersectRanges()

private intersectRanges(array<int, array{0: int, 1: int}> $a, array<int, array{0: int, 1: int}> $b) : array<int, array{0: int, 1: int}>
Parameters
$a : array<int, array{0: int, 1: int}>
$b : array<int, array{0: int, 1: int}>
Return values
array<int, array{0: int, 1: int}>

isAssetPathAllowed()

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

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

ladd()

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

ldiv()

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

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

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

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

lmul()

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

loadEntitiesForCacheTags()

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

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

lsub()

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

materializeDefaultWindows()

Materializes a default-mode day into explicit override rows.

private materializeDefaultWindows(int $calendarId, string $date, int $companyId, TDay $day) : Result

Without it any new override would silently flip the day into override mode and erase the rest of the offer (see openWindow()). Extracted from applyOpenWindow() and reused by ReservationManager::mergeSlots() when a surviving reservation grows over a not yet offered time (no new row of its own, only materialization of the existing ones). On a day that is already an override it is a successful no-op.

Parameters
$calendarId : int
$date : string
$companyId : int
$day : TDay

read fresh under the lock; the caller vouches for it

Return values
Result

mergeRanges()

Merges overlapping and adjacent intervals into a disjoint ascending set, a precondition of the subtraction and intersection below.

private mergeRanges(array<int, array{0: int, 1: int}> $ranges) : array<int, array{0: int, 1: int}>
Parameters
$ranges : array<int, array{0: int, 1: int}>
Return values
array<int, array{0: int, 1: int}>

normalizeEntityRows()

Converts entities to the same normalized shape {@see findDayRowsRaw()} returns, so both paths feed the single {@see computeDay()}.

private normalizeEntityRows(array<int, TimeSlot> $rows, array<int, true> $occupied) : array<int, TCalendarRow>
Parameters
$rows : array<int, TimeSlot>
$occupied : array<int, true>
Return values
array<int, TCalendarRow>

normalizeSqlTime()

Normalizes `H:i` and `H:i:s` to `HH:MM:SS` (the unique index key is compared as a string).

private normalizeSqlTime(string $time) : string
Parameters
$time : string
Return values
string

parseDate()

Parses `YYYY-MM-DD` to the midnight of that day; overflowing dates (such as February 31) are null.

private parseDate(string $date) : DateTimeImmutable|null
Parameters
$date : string
Return values
DateTimeImmutable|null

parseMonth()

Parses `YYYY-MM` to the first day of the month; anything else (even "2026-13") is null.

private parseMonth(string $month) : DateTimeImmutable|null
Parameters
$month : string
Return values
DateTimeImmutable|null

previousTerm()

Returns the original term of the carrier before shortening or splitting (read under the lock), for the e-mail to the client about the term change ({@see ReservationManager::shortenTerm()} and `splitTerm()`).

private static previousTerm(TStoredRow $fresh) : array{date: string, oldTimeFrom: string, oldTimeTo: string}
Parameters
$fresh : TStoredRow
Return values
array{date: string, oldTimeFrom: string, oldTimeTo: string}

rangeFitsWindows()

Returns whether the range `[from, to)` lies entirely inside the union of the offered windows of the day (regardless of occupancy, unlike {@see rangeFitsFree()}, which also subtracts busy). The idempotent shortcut of {@see applyOpenWindow()} and `mergeSlots()`.

private rangeFitsWindows(TDay $day, int $from, int $to) : bool
Parameters
$day : TDay
$from : int
$to : int
Return values
bool

rangeHitsRanges()

private rangeHitsRanges(array<int, TInterval> $ranges, int $from, int $to) : bool
Parameters
$ranges : array<int, TInterval>
$from : int
$to : int
Return values
bool

reconcileCalendarCompany()

Reconciles `calendarId` and `companyId` on the row before the write.

private reconcileCalendarCompany(TimeSlot $entity) : Result|null

calendar_id is the authoritative owner and company_id is only a denormalized derived column (see the class docblock and TimeSlot), so they must not diverge. Two input paths:

  • the caller sent a calendar (calendarId > 0): companyId on the entity is overwritten by the company of that calendar. The calendar wins; mismatched input ("calendar A, but company B") would create a row that belongs elsewhere in one column than in the other, and every later query would see a different world depending on the column used.
  • the caller knows only the company (legacy: current admin appointment form, front end): calendarId is filled from CalendarManager::resolveDefault().

A company without an active calendar yields VALIDATION_FAILED with a readable message, not a silent write with calendar_id = 0 (which would fail on the FK anyway). This happens only when an admin deactivates all calendars, a legitimate state to reject.

Parameters
$entity : TimeSlot
Return values
Result|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

splitTime()

private static splitTime(string $time) : array{0: int, 1: int, 2: int}
Parameters
$time : string
Return values
array{0: int, 1: int, 2: int}

subtractRanges()

Subtracts one disjoint interval set from another; this is where free time is created.

private subtractRanges(array<int, array{0: int, 1: int}> $base, array<int, array{0: int, 1: int}> $subtract) : array<int, array{0: int, 1: int}>
Parameters
$base : array<int, array{0: int, 1: int}>
$subtract : array<int, array{0: int, 1: int}>
Return values
array<int, array{0: int, 1: int}>

sumRanges()

private sumRanges(array<int, array{0: int, 1: int}> $ranges) : int
Parameters
$ranges : array<int, array{0: int, 1: int}>
Return values
int

translateDuplicate()

Translates a failed unique key `(calendar_id, date, time_from)` into a readable error.

private translateDuplicate(Result $result) : Result

DB_DUPLICATE_ENTRY is unreadable for the admin and looks like a system error in the log, yet it is an ordinary collision of two rows in the same minute (the seconds disambiguation of freeTimeFromKey() is exhausted or did not reach this row type).

IMPORTANT: after a unique violation the EntityManager is closed, so only the code may be translated here; any further ORM read would fail.

Parameters
$result : Result
Return values
Result

usesAssetPathTrait()

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

withDayLocks()

Runs a write under a set of day locks and releases them in `finally`.

private withDayLocks(array<int, string> $names, callable(): Result $fn) : Result

IMPORTANT: names are sorted as strings, so two admins touching the same two days from opposite sides cannot deadlock ("A holds 1 and waits for 2, B holds 2 and waits for 1"); a total order over the keys rules out a cycle.

If a lock is not obtained within the timeout, the already acquired ones are returned (otherwise the failed attempt would hold the day until the MySQL session ends) and the caller gets DB_LOCKED with a message showing the situation is temporary, not a data error.

Parameters
$names : array<int, string>
$fn : callable(): Result
Return values
Result

writeShrunkRange()

Writes `time_from`/`time_to` of one row with raw SQL; the only place where {@see shorten()}, {@see split()} and {@see growSlot()} actually write.

private writeShrunkRange(TStoredRow $fresh, int $newFrom, int $newTo) : Result|null

It contains no direction check: the caller does that (applyShorten() inward only, applyGrowSlot() both ways). This method just writes the new range and resolves a possible key collision.

Raw on purpose: save() runs beforeSave() and with it guardActiveReservationRow(), which categorically forbids a time change of an occupied row, the obstacle this narrow admin action bypasses. Only the two time columns are written; day, calendar, company and type are untouched, so none of the other guarantees of the guard lapses.

Moving the start risks the unique key (calendar_id, date, time_from); it is resolved by the same seconds disambiguation as assignFreeTimeFromKey(), only raw (no entity).

NOTE: the Doctrine identity map is bypassed but not broken: a raw UPDATE touches neither the entity nor its snapshot, so a later flush() in the same request has nothing to write and will not revert the values.

Parameters
$fresh : TStoredRow
$newFrom : int
$newTo : int
Return values
Result|null —

null when written; a Result is an error (the caller returns it unchanged)

On this page

Search results