Writing the query-count test that task 14 owed showed the growth was real: one
resource cost 10 queries, six cost 33 — about five per resource, because the
available-minutes figure walked each resource's calendar on its own.
Holidays, tenant overrides and branch hours are identical for every resource in
a report, so they now load once outside the loop; shifts and exceptions load for
all resources in one query each. The batched path is a new method rather than a
change to rawAvailability, which the booking engine also calls. The test pins
the shape of the growth, not an exact count.
Also landed:
- app:segment:seed-templates with beauty, dental and physio presets. Building
four segments and their requirements by hand is the first thing a new clinic
must do and the most tedious; this gives them something to edit instead of an
empty page. It refuses to touch a service that already has segments unless
--force, and it will not invent resource types the tenant never defined.
- book-all is all-or-nothing, proven rather than asserted: with a calendar open
one day a week and a 1-2 day protocol gap, session one finds a slot and
session two cannot, and every session must come back planned.
- credit_refundable: false takes the credit back with a negative adjustment and
deletes nothing — the ledger stays append-only.
- the segments editor has frontend tests, including that it sends back what the
user sees and renders read-only without the permission.
useBranches now returns [] for a non-array payload instead of throwing
"branches.map is not a function" and taking the page down with it.
BookingLocationsScanTest built a Clinic around a Doctor loaded from a different
manager, which Doctrine treats as a new entity; it flushed fine most runs and
failed on cascade in others. It now loads the doctor from the same manager.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 2 of the tenant-marking series. appointments, weekly_schedules and
date_overrides kept their environment implicit in a nullable clinic_id, so
every query that wanted "this environment's rows" had to rebuild
clinic_id IS NULL ? doctor : clinic itself. The two calendar tables also
depended on a MariaDB-only generated column, clinic_key = IFNULL(clinic_id, 0),
purely to make a unique key work across NULLs.
All three now carry the (entity_type, entity_id) pair that service_sections,
patient_records and clinic_staff already use, via a shared TenantOwnedTrait.
The pair is a deliberate denormalisation of clinic_id/doctor_id: the automatic
tenant filter and the tenant-leading indexes both need a real column, and
neither can be built on an IF() expression.
- Unique keys keep doctor_id alongside the pair. A clinic has several doctors
and each has their own schedule, so (entity_type, entity_id) alone would
reject the second doctor.
- clinic_key is gone from both calendar tables.
- appointments gained tenant-leading indexes; EXPLAIN on the panel's list query
now picks idx_appointments_tenant_slot.
Deliberately unchanged, both with the reason already recorded in the code:
active_slot_key stays keyed on doctor + slot, since adding the environment
would let one doctor be booked in their own practice and a clinic at the same
moment. holidays keeps its nullable clinic_id, where NULL means "every
environment" rather than "personal practice" — a meaning the pair cannot carry.
The migration adds the columns nullable, backfills, aborts if any row is left
without an owner, and only then tightens to NOT NULL. It creates each
replacement unique index before dropping the old one, so the tables are never
left unprotected — MariaDB commits implicitly on DDL, so ordering is the only
safety net. It runs its statements through the connection rather than addSql()
because the guard has to sit between the backfill and the NOT NULL change.
Columns are NOT NULL with no default on purpose: a construction site that
forgets assignTenant() fails at flush instead of silently writing entity_id 0,
which the phase 4 filter would then hide from everyone.
Verified on the dev database: 0 rows without a tenant, 0 personal bookings
mismatched against their doctor, 0 clinic bookings mismatched against their
clinic.
Tests: 819 passing (813 + 6 new in BookingTenantTest). PHPStan clean on every
changed file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
APP_DEBUG=0 in .env means doctrine.dbal.profiling, which defaults to
%kernel.debug%, was off in tests too, so doctrine.debug_data_holder was never
registered. Every test calling countQueries() errored out — all four N+1
regression tests had been dead for as long as they have existed. Turning
profiling on for when@test brings the harness back.
Three of the four passed immediately. The fourth was a real N+1: the
service-coverage endpoint batch-fetched its ServiceItem entities to avoid one
find() per row, but ServiceItem maps staffMembers as fetch: EAGER, so hydrating
N items fired N extra collection loads and the batch bought nothing. Six
coverage rows cost 11 queries where one row cost 6.
ServiceItemRepository::findUuidsByIds() returns the id => uuid map as a scalar
query, so no entity is hydrated and no eager collection is touched.
Also adds the query-count assertion for next_available_at that could not be
written while the harness was broken. Confirmed it fails against the previous
per-day implementation (40 queries for 2 locations, 113 for 6) and passes now.
Suite: 411 tests, 2 failures — both pre-existing and unrelated
(LowTierFixesTest, PatientWalletSessionSettleTest).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
next_available_at called getAvailableSlots() once per day for up to 30 days, and
that helper re-read the schedule, holidays and overrides on every call and then
issued an isSlotTaken() query per candidate slot. Cost grew with both the days
scanned and the slots per day, multiplied by the number of locations.
findNextAvailableStart() fetches the schedule, holidays, overrides and blocking
intervals once for the whole window and walks the days in memory.
Measured on the dev data (a doctor with two locations, first opening several
days out): 73 -> 20 queries for one request. The gap widens as locations or the
distance to the first opening grow.
Reserve appointments must keep blocking here: findBusyIntervals() filters
isReserve = false, so reusing it would have reported a reserved slot as free.
Added findBlockingIntervals(), which mirrors isSlotTaken()'s predicate, and
factored both onto a shared builder.
Verified the endpoint returns identical timestamps before and after.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>