Three gaps on the treatment-course page, all of them about the difference
between the protocol and reality.
The sessions table listed each date but not the gap between them, leaving the
operator to subtract two Jalali dates in their head. It now shows the real gap
and colours it as a warning past the protocol maximum.
A course cancelled mid-way stretches silently: the session goes back to
planned and nobody is told. The suggestion endpoint does warn, but only once a
branch is picked, so the warning could go unseen indefinitely. The page now
derives "N days since the last session, past the protocol maximum" from the
course itself, so it shows immediately.
The course's preferred resource was applied by the engine but never named in
the UI. The API now returns preferred_resource_name alongside the uuid, and
the text says plainly that it is a preference — the engine moves it up the
list, it does not hold the slot.
Two backend tests that were owed: the stricter of the protocol spacing and a
spacing policy wins (protocol 7 days, policy 21, effective 21 — otherwise a
clinic's safety rule could be bypassed by writing a short protocol), and a
session whose earliest possible date falls outside the 90-day horizon is
skipped rather than failing book-all, leaving the course untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The deactivation warning was blocked on task 07: there was no way to count "the
appointments on this resource" until occupancy rows linked the two. They do
now, so GET /resource/{uuid} returns upcoming_appointments. It stays off the
list endpoint, where it would be one count query per row.
It is a warning, not a block, and the wording says so: switching a resource off
does not cancel anything, it only removes the resource from future searches.
The panel shows it the moment the "active" box is unticked.
The calendar's exception range still used <input type="date">, which is
Gregorian. Operators say dates in Jalali, and the mental conversion is exactly
where an exception gets recorded a day off. PersianDateInput takes the same
YYYY-MM-DD string, so this is a drop-in swap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mergeable flag was stored, returned by the API and rendered in the editor
while changing nothing. The reason was upstream: the builder only ever read the
primary service's templates, and within one service two segments with the same
name do not occur — so the dedupe it already had could never fire.
Templates now come from the primary service plus every selected item, and
same-named mergeable segments collapse to one. Rules, with their reasons:
- the longest of the same-named segments survives — prepping two areas is not
shorter than prepping the longer one alone
- a duration_source: "items" segment also appears once even when it is not
marked mergeable, because DurationCalculator has already summed every item
and repeating the segment counts that time twice
- the merged requirement count is the maximum, not the sum and not the first
one seen: two areas do not need two rooms, but if one of them needed two
operators, merging must not quietly demote that to one
Also pins that the plan is deterministic: two previews of the same input are
compared byte for byte. A plan that shifts between preview and booking means
the user confirmed something that was not what got booked.
Unrelated but found by running the suite on a Saturday: testPastStartsAreExcluded
searched "last week's Saturday", which is today when today is Saturday, so this
afternoon's slots were legitimately not in the past. It now searches two weeks
back, which is unambiguous on every weekday.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three rows of task 13 were storing data nothing ever read.
`preferred_day_parts` was saved and displayed but never applied when matching.
It was deferred because "evening" has no fixed meaning — but branches already
carry a timezone (DoctorAddress::getTimezone), so the boundaries can be pinned:
morning [6,12), afternoon [12,17), evening [17,22), in the branch's local hour.
The list is now closed and validated; an unknown part is a 422 rather than a
preference that silently matches nothing. The filter runs *before* the cut to
ten recipients — otherwise the first ten slots go to people who did not want
that hour and the real eleventh person is never told.
`markConverted()` was dead code: nothing called it. It now runs off the
AppointmentBooked domain event rather than from inside BookingService, because
converting is a side effect of booking — inside the booking transaction a
waitlist error could roll back the patient's actual appointment. The match is
deliberately narrow (same patient, same service, start inside the window); a
loose match closes a row the patient is still waiting on. It is idempotent, so
redelivery is harmless.
Expiry now exists as a service, a daily scheduled message and
`app:waitlist:expire`. Expired rows were already excluded from matching, so
this is display hygiene, not a behaviour fix: without it the waitlist page
fills with dead entries and the operator cannot tell which are still live. It
sets a status rather than deleting — who waited and never got a slot is data.
Also: a waitlist window is capped at 90 days, matching the booking horizon. An
unbounded window is a row that never expires and shows up in every match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Screenshotting the pages under dark mode and compact density (rather than
trusting that design tokens were enough) turned up two mistakes repeated across
every page this feature set added:
- `.card` carries only the surface, border and radius — padding comes from the
separate `.card-pad`. Fifteen cards were rendering with their content flush
against the edges.
- `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a
control in it produced a joined addon rather than a label above its field.
`.field-block` is the label-above layout, and thirty-seven wrappers now use it.
Both were invisible to type-checking and to the tests, which is exactly why the
visual pass was worth running. Numbers in the new UI now go through
formatNumber so they render as Persian digits, and the utilization page's
header no longer repeats the sentence that appears under its filters verbatim.
The QA driver gained a `--ui` flag: theme and density live in
localStorage['clinicpro-ui'], so without seeding them dark mode and compact
density cannot be screenshotted at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:
- AppointmentCompleted fires from both status-change routes, after the row is
saved. A rejected transition or a version conflict leaves no event; otherwise
the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
confirm plus a cancel, and a consumer that only hears the cancel messages a
patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
as audible as capacity going away, or the resource reads as permanently taken.
Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.
Panel work that these paths made reachable:
- Cancelling from the appointment page now goes through the policy-aware
endpoint and shows the penalty preview before the confirm, so the operator
does not discover the patient's penalty after the fact. The cancellation
service writes the timeline entry itself and accepts a reason, which that
path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
hold steps are identical and only the final step differs. The doctor picker
is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
list is not an error, it means the appointment is slot-based, and that is
exactly what gates the resource-mode reschedule button.
AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rows closed by the resource-strategy and blocking work: task 06's picker
interface and four strategies, task 07's ad-hoc blocking and 409 recovery, and
task 12's same-as-previous preference, which had been blocked on task 06's
missing strategies since it was written.
Tasks 13 and 14 both carried the flaky-suite caveat against their final review;
that flake has a diagnosed cause and a fix, so both now say so instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ad-hoc resource blocking
- "The laser is being serviced this afternoon" is a specific range, not a change
to the resource's working pattern. It stays separate from calendar exceptions
and the modal says which is which — merging them means either an afternoon's
closure lives in the calendar forever, or a change to working hours vanishes
with one click
- Blocking a range that already holds an appointment is refused with 409 rather
than silently taking capacity back; the appointment is still there and someone
has to decide about it first
- Deleting an occupancy that belongs to an appointment is refused too, otherwise
a patient's booking would quietly lose its resource with no record
409 on hold now recovers
Saying "someone just took it" is not enough — the operator would have to search
again by hand. The page drops the stale selection and refetches, so alternatives
are on screen immediately.
Flake, second half
The earlier fix only covered createUser's retry path. Any test that trips a
unique constraint closes the EntityManager, and the next test inherits the same
closed instance from the container. setUp now resets the registry when it finds
a closed manager, so a test's starting state no longer depends on how the
previous one failed.
Three consecutive full runs green: 1340 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Strategies (task 06 debt, task 12 dependency)
- ResourcePicker orders candidates; it deliberately does not choose. Only the
engine knows which resource actually fits this slot and which was already
taken by another role, and a strategy that picked would have to duplicate
both checks
- Four implementations behind a tagged iterator: first_available (name order,
the previous behaviour and still the default because it is predictable),
least_gap, least_loaded, same_as_previous
- least_gap and least_loaded are deliberate opposites and both are correct;
choosing between them is a business decision, so it lives in settings
- same_as_previous lifts a course's preferred resource to the front and keeps
everyone else behind it. A preference, not a filter: forcing the same
operator would make the patient wait two weeks, which is worse than a
different operator
- Availability accepts course_uuid to supply that preference, closing the
dependency task 12 recorded against task 06
- An unknown strategy falls back at search time but is rejected at save time.
Stale settings must not stop bookings; a user typing a wrong value must not
believe it took effect
Test suite flake
createUser() retries on a mobile-number collision — db_test is never reset and
holds tens of thousands of users, so the random draw does collide. The failed
INSERT closes the EntityManager, and the retry asked the container for it
again, which hands back the *same closed instance*. So the retry threw, and
every later test in that process inherited a dead manager.
That is the intermittent "EntityManager is closed" on an unrelated,
always-different test that made roughly half of full runs red and never
reproduced in a subset. Resetting the registry gives a live manager back.
UserCollisionRetryTest pins it by closing the manager on purpose.
Two consecutive full runs are green: 1334 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine from tasks 06 and 07 could find slots and hold them, but nothing in
the panel could actually book one.
- Search, hold, confirm stay three separate steps because they are three
separate states: between seeing a slot and taking it the seat is still open,
and between taking and confirming there is a deadline
- HoldCountdown reads the server's expires_at rather than starting its own
timer at render: browser clock skew and network latency both cost seconds,
and those seconds are exactly where a hold is lost. It turns urgent under a
minute and tells the parent the moment it lapses
- Per-role resource swap offers only the resources the engine returned for that
same slot. Listing every resource in the branch would let an operator pick
one that was never free and collect a 409
- An empty result is not an error: the reason code renders as a sentence
saying what to change
- Confirm requires a doctor and stays disabled until one is chosen — the
endpoint rejects it anyway, and finding that out after the hold clock has
been running is the wrong time
Reached from the appointments page as a separate action rather than folded into
the existing form: its search comes from the intersection of resource
calendars, not from one doctor's slots, and merging the two would confuse both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 08's pricing chain was reachable only through the API, so a clinic could
not define a price list or see what a booked appointment was actually charged.
Price lists
- Draft / active / expired are shown as three states because they mean three
different things operationally: a draft has no effect on today's price at all
- Activation is a separate action rather than a checkbox in the form, matching
the backend rule that creating a list must not change anything
- "Copy" seeds a new list from an existing one starting the day the old one
ends, since most lists are last quarter's with a few numbers moved
- "All branches" is an explicit option, not an empty field
Invoice card
- Renders the recorded chain down to the final amount, hiding zero rows so the
card stays readable
- A missing invoice renders as a normal state, not an error: an appointment
that was never confirmed has no invoice
- Says outright that the numbers are from the appointment's own date and later
tariff changes do not move them — otherwise someone who edited a price
yesterday reads today's older number as a bug
Also corrects task 08's checklist: its test section carried a copy-pasted "no
UI was built" note against rows whose tests have existed since the task
shipped. Replaced with the real test names and the two that genuinely are not
covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 06's engine could only be switched on through the API, and nothing
checked whether the environment was ready for it. Since the mode choice is
irreversible, picking it with no resources defined would lock a clinic into a
state where no appointment is ever computable.
Backend now refuses that: resource mode requires at least one active resource,
with a message that says what to define first. Same shape as the existing
service-mode guard, applied on both save paths.
The panel shows the same conditions as a ✓/✗ list before the choice is made,
each unmet one linking to where it gets fixed — a 422 after an irreversible
decision is the wrong place to learn about a prerequisite.
Also adds the search step (minimum 5 minutes) and extends the existing mode
cards to three rather than building a parallel component.
No strategy picker: task 06 never built the strategies, and an empty menu reads
worse than an absent one.
GET /api/v1/service-items now returns has_segments, computed with one aggregate
query for the whole list rather than one per service.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tasks 04 and 05 shipped working engines that a clinic could only reach through
the API. Both now have the panel that makes them usable.
Groups tab
- Inline min/max per group, saved on blur, with the meaning of an empty maximum
spelled out next to the field rather than left as folklore
- Incompatible / prerequisite rows; the prerequisite-cycle 422 surfaces the
server's own message, which is more precise than anything generic
- A live preview that calls the same service-selection/validate the public site
calls, debounced 400ms. Two separate calculations would eventually show the
operator and the patient different numbers
- The breakdown table shows which item was counted as the anchor and which as
additional, so a surprising total explains itself
Segments tab
- Sequence, duration source, patient-present and mergeable per segment, plus
resource requirements with an explanation attached to each occupancy mode
- A timeline bar whose widths are proportional to duration, with segments the
patient is absent for drawn faded. That contrast is the whole point of task
05: the waiting segment holds the room but frees the operator
- "No eligible resource" renders with a link to add one — an error with no
route forward is a dead end
Task 05's checklist had been left on "not started" this whole time even though
its code shipped with the task; it is now filled in against reality.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tasks 07 through 13 each changed something the rest of the system might want
to know about, with no contract for saying so. And task 05 shipped a powerful
segment editor with no feedback on whether a clinic defined its segments right.
Events
- A closed list of names, because a consumer branches on the string and a
one-letter typo would produce an event nobody hears and no error either
- Payloads carry uuids and scalars only; non-scalars are dropped, not
serialised, so a consumer always fetches fresh rather than reading a stale
detached entity
- record() deliberately does not flush: the event row commits with the change
it describes, so a rolled-back transaction leaves no event behind. A test
pins exactly that
- app:events:publish drains the outbox; five failed attempts park a row with
its error rather than deleting it, because a silently dropped event is a
loss with no trace. app:events:prune only ever removes published rows
Reports
- Resource utilisation separates available, occupied and active minutes.
The gap between occupied and active is what exposes a bad segment
definition, and available is multiplied by capacity so a three-chair room
does not read as permanently over 100%
- A resource with no calendar reports utilization: null, not zero — dividing
by zero means something different from being idle
- Plan accuracy compares planned against actual duration per service and
flags both directions: running short wastes capacity that could have been
sold. Its row links straight to editing that service's segments, because a
report with no route to a fix does not get read
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cancelling worked but had no policy behind it: no window, no penalty, nothing
happened to the deposit, and the no_show status had no effect at all.
Two rules that are expensive to get wrong, and both are load-bearing:
- The clinic cancelling its own appointment is never charged. That check is the
first line of the calculation, not somewhere in the middle, so a later
refactor cannot reorder it into charging patients for the clinic's decision.
- A penalty never exceeds what was actually paid. Anything above that is a
debt, and debt belongs to billing, not to cancellation. An unpaid appointment
is charged nothing and the response says why.
The default is no penalty at all — a penalising default would have made every
patient with a near appointment liable the moment this deployed.
No-shows are rows, not a counter on the patient: a counter loses which
appointment and when, which makes the 12-month window impossible. Crossing the
threshold adds an existing TenantTag; it never blocks the patient, because
blocking is an eligibility policy (task 09) written on top of that same tag.
Waitlist notifies up to ten matching people and the first to book wins. An
exclusive queue reads fairer but means a freed slot sits locked for half an
hour while someone ignores their phone — so the SMS says so explicitly instead.
Insufficient wallet balance does not fail the cancellation: the slot is freed
either way. A slot should not be held hostage to money.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Laser is six to eight sessions; the previous design only knew single
appointments, which is the exception rather than the rule.
- CourseProtocol per service: session count and three distinct spacings —
min is the earliest that is clinically allowed, ideal is best, max is where
the course starts losing its effect
- Starting a course creates every session up front as `planned` and copies the
protocol's numbers and per-session params, so changing the protocol tomorrow
leaves a running course alone
- Suggestions anchor on the last *completed* session, not the course start:
when session 2 slips, session 3 moves with it
- Slots are ranked by distance from ideal, not by earliest available — day 21
is worse than day 27 when 28 is the target
- book-all is all-or-nothing inside one transaction, with a moving anchor and a
90-day horizon; sessions past the horizon stay planned and are reported, not
treated as failures
- The effective minimum is the stricter of the protocol and the task-09 spacing
policy, so a clinic rule never fights the protocol
- Cancelling one session returns only that session to planned; abandoning a
course does not cancel its appointments, which stays an explicit decision
One active course per (patient, service) via active_course_key, the same
partial-uniqueness trick as Appointment::activeSlotKey.
Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the
patient record.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.
Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.
- purchase / consume / refund / adjustment / expiry, each with a reason, an
author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
always has an answer
Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.
Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.
Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 09 shipped a powerful API that a non-technical clinic owner could not
safely use. This closes that gap: activation now requires having seen what the
rule actually does.
- PolicySimulator runs a policy against real past appointments and writes
nothing: evaluation works on facts (never entities), the whole run sits in a
transaction rolled back and cleared in `finally`, and a test counts rows in
five sensitive tables before and after
- activate() now demands a simulation of the *same version* — a report for
version 1 does not unlock version 2
- PolicyTemplateRegistry: six ready-made rules, so the common case never
touches a raw condition
- Severity from the affected ratio; 0% is a warning too, since a rule that
changes nothing usually has a condition that never matches
- An empty clinic still succeeds with a warning, otherwise a new clinic could
never activate anything
Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a
PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a
field that exists only in the schema shows up with no frontend change, and that
operators are filtered per field type.
The schema response now carries per-field metadata (label, type, meaningful
operators) so the form has one source of truth instead of two.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rules become data instead of code: a clinic can say "laser under 18 requires
parental consent" without a deploy.
Engine
- Policy / PolicyVersionLog entities, closed field/operator/effect lists per
category (PolicySchema), condition validation at write time
- PolicyResolver: priority -> specificity -> age, combining effects by
veto / max / sum / union
- A missing fact fails its clause instead of silently passing it
- Policies are drafts until activated, and are versioned rather than edited
Wiring
- selection -> ServiceSelectionValidator
- eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time
- resource + timing -> AppointmentPlanBuilder, including template-less services
- pricing -> PricingEngine, alongside (not replacing) the manual discount
The condition column is named condition_json: `condition` is a MariaDB keyword
and broke every INSERT.
Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a
clinic with no policies sees byte-identical output to task 08.
Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 12 and the fifth closing rule: changing a price never changes an
already-booked appointment.
The pricing chain already existed and worked. Two things were missing. Tariff only
carries a year, so a rate change starting in Mehr could not be expressed — PriceList
now takes an explicit date range and Tariff remains the layer beneath it. And an
appointment stored a single number, so after a price change or a discount nobody
could say what those 2,400,000 rials were made of.
Price resolution walks four layers per service and takes the first hit: branch
override, then the covering price list, then the yearly tariff, then the service's own
price. The last one is the guarantee that a date no list covers still returns a price
rather than zero or an exception. breakdown.sources reports which layer answered, so a
surprising number can be traced instead of guessed at.
Two calculation decisions worth stating. Tax is computed on the patient's share, not
the gross — a patient does not pay tax on the portion the insurer covers. And a
discount larger than the amount floors the total at zero rather than going negative,
because a negative balance would mean the clinic owes the patient money, which nothing
downstream is built to mean.
A branch-specific list deliberately does not count as overlapping a general one; it
takes precedence instead. Treating them as a conflict would have made per-branch
exceptions impossible to express. Lists have no effect until activated, so drafting
next quarter's prices cannot disturb today's.
PriceSnapshot has no setters and a unique key on appointment_id: a snapshot that can
be edited is not a snapshot, and two invoices for one appointment would be two truths.
Corrections are a new row plus voiding the old one. Invoices are written during
confirm with the prices of that moment — computing later would let a rate change
between booking and invoicing produce a different number, which is exactly what rule
five forbids.
12 tests. The one that matters is
testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange: book, double the
service price, watch quote return the new number while the appointment's invoice
returns the old one. Without it rule five is only a claim.
1220 tests / 3551 assertions. phpstan back at its 14-error baseline. Frozen slot
contract green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 11 and the third closing rule of the design document: preventing a double
booking is the database's job, not the code's. Any "is it free?" check in PHP has a
race window between the read and the write — two concurrent requests both see free
and both write.
MariaDB has no range EXCLUDE constraint, so every occupied interval is broken into
fixed five-minute buckets under UNIQUE(resource_id, bucket_at, seat). The code only
INSERTs; a rejection from the database *is* the answer. `seat` carries capacity: a
three-bed room has seats 0..2, allocation walks upward on each collision, and the
fourth concurrent hold finds nowhere to sit. Counting capacity in PHP would have
rebuilt the very race this removes.
Buckets are written through DBAL rather than the ORM on purpose: a unique violation
raised inside flush() closes the EntityManager, and the next seat attempt would then
fail with "EntityManager is closed", hiding the real outcome.
Occupancy is one row per (segment × resource). The reference test asserts the payoff
directly: for a 55-minute appointment of numbing / waiting / laser, the room gets
three rows and the operator only two — the operator holds nothing during the wait and
stays bookable for someone else.
A partial hold never survives. If the second resource has no room, the first is
released and the hold itself removed; otherwise a resource stays locked for an
appointment that will never exist.
Confirming does not re-reserve anything — the seats were taken at hold time and only
the label changes. Re-reserving on confirm would reopen the race the hold closed.
Cancelling marks rows `released` instead of deleting them, because the history of
which resource was busy when is the input to the utilisation reports; the uniqueness
buckets *are* deleted, or that interval would stay locked forever.
Expired holds are released by the existing scheduler rather than a new one. That
exposed a bug in my own change: the flush guard used $count, which now includes
released holds, so reset([]) could pass false to save(). It is guarded on $expired.
The appointment itself is still built with the existing constructor, so
active_slot_key, events and the payment path behave exactly as before — the
multi-resource occupancy sits beside them, not instead of them.
12 tests. Two matter most: the second hold on the same resource and interval getting
409, and a test that writes a duplicate bucket row over a *separate connection* and
expects the unique-key violation — if that one ever passes silently, the guarantee
had moved back into the code.
1208 tests / 3495 assertions. phpstan at its 14-error baseline. Frozen slot contract
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 10 of the design document, and the payoff for tasks 01–05. The engine slides
a multi-segment plan across resource calendars and answers which times are actually
possible, with a suggested resource for each role. Until now the only conflict the
system checked was the doctor's; rooms, devices and operators did not exist.
Allocation is per *role*, not per segment, and that is what returns the wasted
capacity. An operator with no requirement during "waiting for the cream" is simply
not examined for those minutes, so another patient can use them. The reference test
encodes exactly that: patient A holds 10:00–11:00 while the operator is only busy
10:00–10:05 and 10:35–11:00, and patient B is offered a slot inside the gap with the
second room assigned. The spec says the task is not verified without that scenario.
One resource is chosen for every segment that needs its role, not independently per
segment — otherwise the operator in segment 1 and segment 3 could be two different
people and the patient would change hands mid-treatment.
Occupancy is stored one row per (segment × resource) rather than one per appointment.
The granularity is the whole point; a row per appointment would re-create the
single-interval model the design rejects. Reserved intervals are widened by each
resource's setup/cleanup, because the resource genuinely is not available then.
booking_mode gains a third value, resource, alongside slot and service. It is purely
additive: the default stays slot, no environment moves on its own, and a location
that has not opted in keeps the untouched legacy path. The frozen slot-mode contract
stays green.
Performance is a test, not a hope: 30 days, 20 resources and 500 existing bookings
complete well inside the 500ms budget. Every input is read once and the rest is in
memory — no query inside the day or candidate loop — and candidates are generated
only from the free windows of the scarcest role, which turns tens of thousands of
candidates into a few hundred.
An empty result is not an error and not a 404: it carries
reason: "no_capacity_in_range" so the caller does not have to infer meaning from
emptiness.
Also fixed a genuinely intermittent test defect: NumericFieldNormalizerTest padded a
random number with the three-byte Persian "۰" using byte-based str_pad, producing
broken UTF-8 whenever the number was short. It failed roughly at random. The improved
assertion message added earlier is what identified it immediately.
1196 tests / 3414 assertions. phpstan at its 14-error baseline.
Resource-picking strategies, the availability cache and the settings UI are recorded
as outstanding in the checklist with reasons — the cache in particular would be
premature while the performance test passes comfortably without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 7 of the design document, and the reason the whole resource layer exists.
A laser session is not one block: numbing cream (5 min, room + operator), waiting for
it to work (30 min, room only), the laser itself (20 min, room + operator + device),
aftercare (5 min, room + operator). Under the single-interval model the operator is
locked for all 60 minutes while actually working 30 — half the capacity thrown away.
AppointmentPlanBuilder turns (service, selected items, branch, patient) into a plan:
segments with offsets, durations and resource requirements. It deliberately assigns
no absolute time and no specific resource — that is the next task. This only produces
the *shape* of the appointment.
Segment duration comes from one of two sources. A fixed segment carries its own
number; an item-driven one gets its duration from task 04's DurationCalculator, so
"the laser itself" grows with two treated areas while "waiting for the cream" does
not. One number could not have expressed that.
Three contracts worth stating:
- A service with no segment templates falls back to a single continuous segment
requiring the doctor resource — exactly today's behaviour. Without it every
existing service would have become unplannable overnight.
- A segment with no requirements is valid: "waiting at home" consumes time but
occupies nothing.
- same_gender_as_patient with an unknown patient gender is a 422, not a silently
dropped requirement. Dropping it quietly would route the patient to a resource the
clinic said must not serve them.
When no resource qualifies, the error names the role, the skill and the branch —
"no female operator with the skill «Alexandrite laser» is available at «Central»" —
rather than an empty result the caller has to interpret (section 10).
occupancy_offset carries each requirement's setup/cleanup minutes for the availability
engine. It is taken as the maximum across candidates, because the builder does not yet
know which resource will be picked and under-reserving means the next appointment
lands on top of the cleanup.
11 tests covering the document's reference example (offsets 0/5/35/55, total 60),
item-driven scaling, the no-template fallback, all three gender-constraint outcomes,
merging and both caps. 1186 tests overall. phpstan back at its 14-error baseline;
slot-mode frozen contract green.
The admin segments page is not built; the checklist records it with a target. The
backend and preview endpoint are complete and consumable without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section 5 of the design document rejects summing service durations. "Face + bikini"
is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not
happen twice. Seven wasted minutes times twenty appointments a day is an hour of
capacity lost daily, and AppointmentController was doing exactly that plain sum.
Each item now carries a solo duration and an additional duration. One item counts at
its solo duration and the rest at their additional; the anchor is the item with the
*largest* solo duration rather than the first one selected. Anchoring on selection
order would have let the same basket cost different amounts depending on click order,
so a patient could buy a shorter appointment by reordering. Largest-first is also
conservative: no combination is ever under-estimated, and under-estimating pushes the
next appointment on top of this one.
additional_duration_minutes stays NULL by default and the entity reads NULL as "same
as solo", so every existing service keeps behaving exactly as before — the 236
appointment-domain tests pass unchanged. The old duration_minutes column is kept and
written in step rather than renamed, because other consumers still read it.
ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line
change task 00 predicted when it deliberately preserved the naive sum.
Selection rules are data, not policy: min/max per group is a number, and "bikini does
not combine with full body" is a relation. Putting either in a rules engine means
several rules per service and nobody able to explain a rejection. Validation returns
*all* errors at once rather than the first, since a user with three problems should
not make three round trips. Prerequisite cycles are rejected at write time — storing
both "A requires B" and "B requires A" would make every selection permanently invalid.
Named CatalogCategory, not ServiceCategory: that name is already an insurance enum
(outpatient/inpatient) living on ServiceItem itself, so the two would have collided in
the same file's imports.
Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key
that may not exist, which warns instead of yielding null.
1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without
this change (verified by stashing). Slot-mode frozen contract green.
The admin UI tab for groups and relations is not built; the checklist records it as
outstanding with a target. The backend is complete and
POST /service-selection/validate is consumable without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes task 03. The resource calendar page edits weekly shifts, records leave and
maintenance, and previews two weeks of availability with a Persian reason for every
empty day — showing the raw server key ("outside_branch_hours") to a user would have
been a meaningless message. The preview is labelled raw on the page itself, because
booked appointments are not subtracted yet and mistaking it for bookable time leads
to overbooking.
The interval algebra moved to src/Shared/Time/TimeInterval.php with twelve unit
tests: tasks 05 and 06 need the same union/intersect/subtract, and a second
implementation is how two subtly different definitions of "overlap" get born. The
half-open [start, end) contract is what makes a shift ending at 13:00 and one
starting at 13:00 not overlap.
AvailabilityQueryCountTest locks the query count flat: one day and ninety days cost
exactly the same number of queries. Without it the first refactor can put a query
inside the day loop and a 90-day response quietly becomes hundreds of queries —
something only production would reveal.
app:resource:calendar:backfill derives shifts from existing WeeklySchedule sessions,
so the resources created in task 02 are not left with empty calendars. It skips any
resource a user has already configured, which is also what makes it idempotent. The
weekly schedule itself is untouched: this is a copy, not a migration.
Also added --replace to the holiday import. upsert keys on the date, so a row written
with a *wrong* date can never correct itself — re-running just creates the right row
beside the wrong one. That is exactly what happened after fixing the Jalali
conversion bug, and it was caught while capturing real responses for the docs.
Deferred with reasons recorded in the checklist: seasonal shift validity (two
nullable columns can be added later without backfill, so "needed from day one" does
not hold), and a Jalali date picker in the exception form.
1154 tests / 3229 assertions. phpstan at its 14-error baseline, none in touched
files. tsc clean, vitest 622 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>