What an operator writes down after treating an area is decided by the device,
not by the service: a laser has energy, pulse and shot count, an RF unit has
something else. So the field list lives on the resource type, and adding a new
kind of device becomes a settings change rather than a migration.
One validator covers both directions — the schema when a manager saves it and
the values when an operator submits them. Splitting them would let a schema be
stored that no value can ever satisfy.
A value whose key is not in the schema is rejected rather than stored: silently
keeping it means the operator believes they recorded something that will never
be shown back to them. Option matching compares as strings so "18" and 18 are
one option, not two.
The migration seeds the laser type's three fields onto existing rows that have
none, so clinics already running laser devices do not start from an empty form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening a case copies what must not move afterwards — the session count and the
list of body areas, each with its category name — because a treatment record is
a medical document and editing settings tomorrow must not rewrite what was done
yesterday. The areas are the leaf categories under the service's own category:
"توتال" contains bikini, leg and hand, and treatment happens on those three, not
on the grouping node above them. A category with no children is its own single
area, so "لیزر دست" gets one area rather than none.
Every session in the course is created up front so that "session 5 of 8" has
somewhere to live, but none of them is booked: creating eight real appointments
would lock eight months of slots for a patient who may not attend session three.
CategoryClosureResolver gains leaves(); the graph walk it already does is what
tells a leaf from a grouping node, so this belongs next to descendants() rather
than in a second traversal elsewhere.
TreatmentCase and TreatmentSession carry no money field, and must not: billing
lives on PatientSession, which is created when an appointment is confirmed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A protocol says a course of a service runs over several sessions, when each
falls due, which doctor supervises it and which staff may perform it. The row
existing IS the "طول درمان" switch, so there is no separate boolean that could
disagree with the step list.
Each step's offset is measured from the previous session rather than from the
start of the course: laser spacing is a clinical requirement — hair regrows
relative to the last treatment — so a late patient shifts the rest of their
course instead of getting the next session early. That also lets one course use
uneven gaps, which a single min/ideal/max triple cannot express: a botox course
is session 1, then +15 days, then monthly.
Steps and staff are cleared and rewritten in two flushes inside a transaction.
A single flush sends inserts before deletes and the replacement row collides
with the unique (protocol, step_number) index — caught by the replace test.
Removes docs/api/course.md and the task-12 folder. They documented src/Course/,
a module deleted in 65d5831c whose commit message only mentions removing two
test files; that design is superseded by this one.
ServiceItem::$sessionCount is marked deprecated. It never had logic behind it
and session count now comes from the protocol; the column stays in payloads so
existing clients keep working.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A practice domain is the field a clinic operates in — beauty, dentistry —
and unlike Specialty it is configuration, not a label: treatment workflows
will bind to its code, so the code is immutable once created and only a
platform admin can mint one. A clinic that has not chosen a domain keeps
behaving exactly as it does today.
Assignment reuses PATCH /api/v1/clinic/{uuid} rather than adding a second
endpoint. An unknown domain uuid is rejected instead of silently dropped,
because a lost selection would only surface at the first protocol-driven
booking.
Also corrects ADR-0003: resource occupancy does not in fact guard the panel
booking path, which writes appointments.resource_id and no occupancy row at
all, so the doctor slot key cannot simply be dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Add RecordNumberSettingsController for managing patient record number patterns.
- Create RecordNumberPattern entity to represent the pattern configuration.
- Implement RecordNumberPatternRepository for database interactions.
- Develop RecordNumberGenerator service for generating and validating record numbers.
- Add tests for record number generation, backfilling, and API interactions.
- Ensure proper access control for viewing and updating patterns based on user roles.
Resources never needed a branch: devices and rooms belong to the clinic
itself, and the picker always had exactly one option — a mandatory click
that decided nothing.
- `address_uuid` is now optional on resource and pool creation; when it is
missing the environment's own address is used. Clients still sending it
keep working.
- The panel no longer asks for or displays a branch anywhere: resource
form, list column and filter, pool form and column, detail row, and the
resource-first booking page.
- Availability no longer gates on `doctor_addresses.active`. That gate shut
down every device of a clinic whose address row happened to be inactive,
with a message no page in the panel could act on — no endpoint writes
that column at all.
`address_id` stays on the resource: the timezone and the tenant pair are
derived from it. It is simply no longer the user's decision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Add service timeline builder for appointments to manage available slots.
- Create a hook to fetch resource booking services with effective durations.
- Develop ResourceBookingSlotController to handle API requests for resource booking slots.
- Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations.
- Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
The resource booking modal groups services under their section, the way the
doctor's service booking does. The offering list had no section, so the list
could only be flat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
my/clinic-doctors now reports has_schedule per doctor, and the appointments page
builds tabs from it. A doctor with no working days had a tab that could only ever
show an empty timeline.
The flag is resolved with one query for the whole list rather than one per
doctor. Clinic owners now read this authenticated endpoint too instead of the
public clinic doctor-list, which is where the flag lives; admin keeps the public
list and, with no flag present, hides nobody.
Also repairs fallout from making the resource supervisor mandatory: four test
classes build resources through their own helpers and were failing with 422. The
supervisorFor helper moved to ApiTestCase so all domains share one, rather than
copying it per suite. Full backend suite is green again (1306 tests) — the
previous commit only ran tests/Resource and missed this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Supervision now lives on the resource itself instead of being asked for again at
booking time, so one relation answers it everywhere.
The column is deliberately separate from the existing doctor_id bridge. That
bridge means "this resource IS this doctor" and isPerson() uses it to pin
capacity at 1; a supervised three-seat device must not become a person resource.
The FK is SET NULL rather than CASCADE because deleting a doctor should not take
the clinic's laser with it.
Required on create and non-clearable on update, enforced in the API where it can
give a Persian message. Ownership is checked through Clinic::hasDoctor so a
secretary cannot put their device under a doctor of another clinic; that returns
404, not 403, keeping foreign data invisible.
The 13 existing resources are backfilled deterministically: a practice resource
gets its own doctor, a clinic resource gets that clinic's first doctor. Both are
editable from the resource form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Implement real-time validation for overlapping shifts in the ResourceWorkingHoursPanel.
- Remove the copy shift functionality to simplify the UI and prevent confusion.
- Introduce ResourceExceptionsCard to manage resource exceptions, including leave and maintenance.
- Update ClinicAppointmentSettingsPage to utilize new components and improve tab navigation for resource management.
- Add comprehensive validation tests for resource calendar to ensure overlapping shifts are correctly handled.
- Update API documentation to reflect new validation error messages and rules.
Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.
- drop PriceList/PriceListItem, their repositories and the seven
/api/v1/price-list(s) endpoints; PricingController keeps only quote and
the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
/service-items/{uuid}/tariffs endpoints; creating or repricing a service
no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
duration columns, which DurationCalculator and ServiceSelectionValidator
still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
tariff modal and the service detail tariffs tab; useAppointmentInvoice
moves to its own hook file
Migration drops price_lists, price_list_items, service_tariffs and the
override price column.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three views become two. The resource lanes were a separate tab, which meant
reading a doctor's free hour on one screen and the laser's on another and
matching them by eye — while in the resource-first model it is the device and
the room that decide whether that hour is really free. They now sit under the
same "زمانبندی" view, below the doctor's slots.
Each lane says how much of its shift is still free, and that number respects
capacity: a minute counts as busy only once the overlapping bookings reach
the resource's capacity, so a three-bed room with two appointments is still
open. Treating it otherwise would silently turn every multi-capacity resource
into a single-capacity one. ResourceFreeTimeCalculator does the sweep and
carries nine cases of its own.
only_bookable=1 keeps resources with no service offering out of the view;
they could only ever render an empty lane. On the seeded clinic that is five
resources down to two.
Two ruler defects the screenshot caught: hours rendered in Latin digits, and
the last label was half-clipped by the container so 21 read as 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The holiday model was already right — national holidays global, a per-tenant
override in both directions, per-doctor and per-resource exceptions — but
nothing could create a national holiday. The only writer was an import
command, so the calendar the whole product inherits from had no owner.
Three admin-only routes give it one. POST upserts, because `date` is unique
and re-sending a day should rename it rather than surface a raw database
error; PATCH takes only the title, because moving a date means a different
holiday. The system admin has no work environment, so the list endpoint now
returns the calendar with an empty `overrides` for that role instead of the
403 `pair()` would raise — the person who maintains the calendar has to be
able to read it.
Both holiday tabs — the doctor's and the resource's — now open with the
official calendar above their own exceptions, from one shared card rather
than two copies that would drift. Each row can be opted out of with a single
click, which is the existing holiday-override endpoint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Branches and rooms are not part of the resource-first product: a room is a
resource like any other, and the only thing the branch pages still managed —
opening hours — duplicated the resource's own shift.
What could not go is the address. Every appointment carries address_id (75 of
75 rows), the public booking site reads /clinic-pro/doctor-address/{id}, and a
resource derives its tenant pair from the address it belongs to. So
DoctorAddress stays as an invisible anchor with no page and no menu entry, and
GET /api/v1/addresses replaces GET /api/v1/branches for the forms that still
need to say "where".
BranchResolver was likewise not a branch feature. doctor_addresses is a global
table, so TenantFilter does not cover it and eight callers across booking,
availability, pricing and the catalog went through this resolver to avoid
leaking another clinic's address. It moved to Doctor\Service\AddressResolver
rather than dying with the domain.
The availability engine loses one layer: a resource's real hours were the
branch hours intersected with its shift, and are now the shift alone. That is
the single behavioural change, and the three tests that asserted the old
contract are replaced by one that states the new one.
Rooms already had a resource row each; the migration drops only the bridge
back to `rooms`, and drops it before the table — that foreign key is ON DELETE
CASCADE and the other order would take the resources, and their appointments,
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The appointments page only ever showed one doctor's row, but in the
resource-first model a single appointment can hold a room and a device at
the same time, and that — not the doctor's schedule — is what runs the
capacity out. An hour could look free on the doctor's lane while the only
alexandrite laser was already taken.
A third view, "منابع", draws one lane per resource for the selected day.
Blocks come from resource_occupancy rather than the appointment: that range
includes the device's setup and cleanup minutes and is the same range the
availability engine treats as busy. A multi-segment appointment therefore
shows up on every resource it holds, and each block links to the
appointment it belongs to.
GET /api/v1/resources/timeline keeps a fixed query count — one for
occupancy, one for shifts, one for the patient names — instead of one per
resource.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The service page could not say which category a service belongs to, so the
containment edges defined in settings had nothing to match against.
A Categories tab now selects one — and only selects. Creating, renaming and
deleting stay in Settings > Categories: if every page could create one,
"whole body" would exist three times with three spellings and the
includes edge would stop catching anything.
PATCH /api/v1/service-item/{uuid} carries the choice as
catalog_category_uuid. Absent field leaves the current category alone, null
clears it, and a category from another environment is refused with 422 —
the uuid arrives in the request body where TenantFilter does not reach.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Categories are the taxonomy both services and resources select from, but
until now they could only be reached through the service catalog, so every
environment ended up with its own spelling of "whole body".
- Settings > Categories page: global CRUD plus the "includes" edge
- POST/GET/DELETE /api/v1/service-category/{uuid}/includes — a DAG, kept
separate from `parent` because "hand" sits under both "whole body" and
"upper limb"; a cycle is refused with 422
- PUT /api/v1/resource/{uuid}/categories — full replacement, and a category
from another environment is rejected explicitly since the uuid arrives in
the request body where TenantFilter does not reach
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST /api/v1/appointment now accepts resource_uuid. When the resource is a
doctor the doctor is inferred from it, and the booking clinic is derived from
the resource's branch — sending clinic_uuid separately was only ever a way to
make the two disagree. The doctor-only path is untouched, which the public site
depends on since it sends nothing else.
Two guards before the booking is built. The resource must belong to the same
environment as the booking: it arrives as a uuid from the request body, so
TenantFilter does not cover it and without the check a patient could attach
another clinic's device to this clinic's appointment. And a resource that does
not offer the requested service is refused up front rather than discovered when
the patient turns up. That second check runs over the items the calculator
already validated rather than re-reading uuids, which is also why the
tenant-lookup inventory stays where it was.
GET and PUT /api/v1/resource/{uuid}/services manage the offerings. The list
returns the effective duration and price along with which level produced each,
so the panel can label an empty cell "30 minutes — service default" instead of
leaving the user guessing whether it is unset or zero. PUT replaces wholesale,
like the skills endpoint: a row absent from the body is a row the user removed,
and an empty string clears an override back to inheritance rather than setting
zero.
findEligible now also orders by category coverage — a device registered for
"foot" sorts ahead for a foot service. Ordering, not filtering: a clinic that
categorised only some of its devices would otherwise lose the rest.
Thirteen tests across the two files. Suite 1304 green, phpstan at its 14-error
baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An appointment could say which services it was for but not which resource
performed them, so a booking on laser #2 was indistinguishable from one on
laser #1. Both columns are nullable: the appointments that already exist have
no resource and the migration must not break them.
resource_id is not a duplicate of resource_occupancy. Occupancy records what
was held and when — including rooms and devices held for a single segment. This
column records what the appointment is *for*, which is what the panel lists and
what the patient chose.
The option is kept separately from service_item because duration and price
resolve from the resource+service+option triple; without knowing the option,
the stored number cannot be explained later.
Tests: the resource and option survive a round-trip, stored minutes come from
the resolver rather than the service default (15 where the service says 30),
raising the tariff afterwards leaves the earlier snapshot at 8M, and an
appointment with no resource still serialises with nulls instead of failing.
Suite 1290 green, phpstan at its 14-error baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps against the spec. Resources could not be categorised at all — only
services carried a catalog category — so "this device is for hands and feet"
was unsayable. And CatalogCategory::$parent is a tree built for menu ordering:
one parent per category. Laser areas overlap, so "hand" belongs under both
"whole body" and "upper limb" at once, which a tree cannot express.
Containment is therefore a separate directed acyclic graph
(catalog_category_includes) sitting beside the display hierarchy, and resources
join the existing clinic-wide categories through a many-to-many rather than
growing a parallel list of their own.
CategoryClosureResolver walks it transitively: whole body includes lower body
includes foot, so whole body includes foot without anyone writing that pair
down. The walk reads every edge of the environment in one query and traverses
in memory — a query per level would tie round-trips to graph depth. The visited
set doubles as the cycle guard, so even data that already contains a loop
cannot hang the traversal, and assertNoCycle refuses to create one.
Selection now rejects picking an area together with a category that contains
it: "whole body laser" and "hand laser" in one appointment is a 422 with a
Persian message naming both. This replaces hand-written incompatible_with pairs
for the area case — defined once on the category instead of per item pair —
while that relation stays for incompatibilities that have nothing to do with
areas.
Nine tests, including the two-parents case a tree could not hold, the cycle
refusal, the self-edge, and the empty-graph boundary. TenantSchemaCoverageTest
caught the new edge entity as unclassified; it is registered as an aggregate
child of the parent category, which is what the constructor already enforces.
Suite 1286 green, phpstan at its 14-error baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
findEligible matched on address, type and skills, so two devices of the same
type were interchangeable even when only one of them performed the service.
It now also consults the offering table.
The filter is conditional on purpose: it only applies once the clinic has
registered at least one resource for that service. Applying it unconditionally
would leave every environment that has not filled the links in yet without a
single free slot overnight — a silent outage caused by a feature they never
opted into. When rows do exist but all are inactive the result is empty, which
is the honest answer: nobody performs this right now.
The service comes from the segment template rather than the root service. One
appointment's plan can carry segments from several items, and "who can do this"
is a per-item question.
Five tests: the filter picking one of two identical devices, the no-rows
passthrough, the all-inactive empty, the no-service-argument path still
untouched, and the filter stacking with the skill filter.
Suite 1277 green — including the 27 existing plan and availability tests, which
is what proves the backward-compatible path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chain the spec asks for, plus the branch level that already has data:
resource+option, resource+service, branch override, then the item's own value.
Duration and price resolve independently. If they resolved together the first
override would silently swallow the other value — a resource that only differs
in how long it takes would also drop the branch's tariff.
Each resolved value carries where it came from. Without that, the panel cannot
label a number "from the branch" or "service default", and "why this number?"
becomes a four-table investigation.
Two rules worth stating: null means inherit while zero is a real value, so a
free service keeps its zero instead of inheriting the parent's price; and an
inactive offering is skipped whole, since "this resource does not perform this
right now" is not the same as "I have no opinion on the numbers".
The parent service is passed in rather than looked up from the item's group.
The booking flow already holds both, and a reverse query would be a second way
to answer a question that already has an answer in hand.
Eight tests: one per level with the other levels populated so the winner is
provable, plus independent resolution, the inactive skip, zero, and resolving
the service itself without a parent.
Suite 1272 green, phpstan at its 14-error baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Until now a resource was picked by type and skill alone, so two devices of the
same type were indistinguishable even when only one of them performed the
service — and there was nowhere to say that this doctor takes 30 minutes for a
filler while that one takes 45.
ResourceServiceOffering is that link: resource ↔ service item, with an optional
duration, an optional price and an active flag. Because a "service option" here
is itself a ServiceItem inside an ItemGroup, one table covers both levels the
spec asks for — a row against the parent item is "resource + service", a row
against a member item is "resource + option". A third table would have meant
two sources of truth for one concept and a rewrite of every path that already
speaks ServiceItem.
It is an aggregate child of ClinicResource, like ResourceSkill: no tenant
columns of its own, since the resource already carries the pair and a copy is
just something that can drift. The constructor refuses a resource and a service
from different environments — TenantFilter does not cover that case, as both
uuids arrive from the request body and the filter does not apply to aggregate
children.
null means inherit, not zero: an explicit zero is a duration that does not
exist, while null means this resource has nothing to say and the resolver
should look one level up. Zero and negative values are rejected outright.
Tests cover the pair being stored, the duplicate pair hitting the unique
constraint, the cross-environment guard, null-means-inherit, one service across
two devices with different numbers, and deactivating without losing them.
Suite 1264 green, phpstan at its 14-error baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Removed package consumption flags and related properties from PriceQuote.
- Eliminated unused domain event publishing for policies and waitlist in Schedule.
- Cleaned up BookingEngineSeeder by removing package and policy related logic.
- Updated SeedScenariosCommand to reflect removal of policies from output.
- Dropped policy, package, treatment course, cancellation, waitlist, and domain event tables in migration.
- Removed domain event assertions from tests related to resource blocking.
POST /api/v1/appointment resolved the selected services, summed their minutes,
used that to compute slot_end — and then dropped the result. It never called
replaceServiceItems() or setServiceDuration(), so an appointment booked from
the public site kept no record of what it was booked for:
- the patient panel showed neither the service nor the duration
- reports counted the appointment as having no services
- a later reschedule had no duration to preserve
The management path did all of this correctly; only the public path did not.
Found by booking through the real endpoint and looking at the panel, which is
the one thing no test did.
The duration was also computed as a naive sum of duration_minutes, ignoring the
solo/additional split. That made a multi-service booking's length disagree with
the slots appointment-service-slots had just offered the patient — the booking
would occupy a different span than the one shown. Both paths now go through
ServiceBookingCalculator, which is what builds those slots.
For data that only sets duration_minutes, the calculator returns the same total
as the old sum, so existing services are unaffected.
assertServicesMatchContext() is gone: the calculator performs the identical
ownership check with the same error code and message, and the tenant-lookup
inventory is updated to match.
Tests: PublicBookingServicePersistenceTest starts at the endpoint rather than
building an appointment in memory — the gap that let this ship. Verified it
fails (4 of 8) with the fix disabled. Full suite 1433 green, slot-mode-frozen
green, phpstan at its 14-error baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last structural gap from task 05 was the third occupancy mode. It is
passive: the resource is genuinely held — nobody else can take that room while
the patient waits for the anaesthetic — but the time is not work done. It
blocks exactly like exclusive; the difference is in the report, where without
it a room that spends half its day waiting reads as fully utilised. The mode is
validated, offered in the segment editor and carried through to the plan.
Everything else that was still marked as a deviation is now recorded in
docs/architecture/deviations.md, one row each, in the form "what the plan said
/ what was built / why". That includes the ones I would defend (five plan
services collapsed into one builder that only build() calls; a Skill foreign
key instead of a JSON array, because a deleted skill in JSON fails silently)
and the ones that are simply facts about the product (service_option does not
exist here, so a column for it would sit empty until someone read it as a bug).
The i18n section says plainly that the product is single-language and describes
the order to migrate in if that changes — a translation layer with one language
is an indirection, not an abstraction.
All sixteen checklists now read zero pending and zero unresolved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three were deviations I had argued for. Reversing them as asked, each in
the shape the plan wanted and with the failure it would otherwise cause closed.
consume now catches the unique-constraint violation, as specified, instead of
relying only on a read-before-insert. The read stays for the ordinary path, but
it never closed the race — only the unique key does. What made the catch
dangerous is that Doctrine closes the EntityManager on a constraint violation
and the rest of the request dies with it, so the catch resets the registry.
Without that, "already consumed" would surface as an unrelated 500. A test
inserts the ledger row from a second connection and then asks the service to
consume: it returns true, the manager is still open, and exactly one session is
taken.
Cancellation is one transaction now: status, capacity release, credit refund,
penalty and the timeline row commit together. An appointment marked cancelled
whose capacity was never released is the worst of both — the patient has no
appointment and nobody can take the slot. Notification stays outside the
commit, because an SMS cannot be rolled back and must not sit inside something
that can. A test with an SMS provider that always throws proves the
cancellation still commits.
The ledger's running balance is computed in the UI from the rows on screen. The
server still sends its own and remains the reference; the point of computing it
here is that the column now reflects the rows the user is actually looking at,
so a truncated list shows up as a mismatch rather than as a number nobody can
check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MIN_SAMPLE goes back to the specified 10. The reason it had been lowered to 3
was real — a small clinic saw an empty report — but the fix was wrong: three
samples do not make an average, and calling that "accurate" is worse than
saying nothing.
Rows below the threshold are now returned rather than dropped, with severity
null and below_min_sample true. That refuses both mistakes: it claims no
severity it cannot support, and it does not show a small clinic an empty page
that implies everything is fine. They sort after the usable rows and render
faded with a "small sample" badge.
The utilization page gets its Recharts bar chart. The table stays underneath —
six numeric columns are not something a chart answers — but the one question
the table is bad at, "which resource is behind", is exactly what a chart is
for. Colours come from the design tokens rather than hex, which is where a
chart usually breaks in dark mode, and a resource with no calendar is left out
entirely: null is not zero, and a zero bar would be a lie.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The task 09 architecture specified FieldRegistry, OperatorRegistry, six engine
classes and a stored specificity. What shipped was a single PolicySchema
constant list, six operators, one resolver and a specificity recomputed on
every booking. Each shortcut was defensible on its own; together they left the
starred risk the task itself recorded — a field can be advertised in the form
and supplied by nobody, and the rule silently never matches.
OperatorRegistry now holds all eleven operators. The five that were missing are
real capability, not ceremony: greater_or_equal and less_or_equal make boundary
rules expressible without off-by-one, not_in is the natural way to write an
exclusion, between stops "18 to 65" needing two clauses, and days_since is the
documented operator for "more than N days since" — until now every caller
computed that by hand. between is inclusive at both ends because that is what
the Persian phrasing means and what the user will type.
FieldRegistry is now the single source: it builds the form schema and extracts
the value, so a field that exists in one and not the other is impossible. It
also declares which categories each field belongs to, which is what the closed
list per category used to do separately. Adding it immediately caught its own
first case — last_visit_at was advertised and supplied nowhere, so the guard
now populates it and days_since has something to read.
The six engines are thin on purpose. They give the call site a type — "the
pricing engine" rather than "the resolver with the string pricing" — and a
place for evaluateIsolated, which the sandbox needs to answer "what would this
one rule do". Conflict resolution and effect combination stay in
PolicyResolver: six copies of that would be six places to break.
specificity is a stored column now, computed on save with the documented
weights, and the migration backfills existing rows with the same formula. Left
at zero they would all have tied and the ordering would have changed overnight.
Field names stay as they are rather than moving to the document's dotted names
(patient.age). Stored condition_json rows point at the current names on live
clinic policies; renaming them is a data migration, and the mapping is not
one-to-one — implementation_notes.md says as much.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cancellation policy page showed only the tenant policy, so nothing said
which services opt out of it. Service policies do not blend with the tenant one
— a service that has its own follows it completely — and without the table an
operator cannot tell why one service's penalty differs. It lists them with a
link to each service.
The waitlist had the matches endpoint and no way to reach it. The list answers
"who is waiting"; the question asked when capacity frees up is "who is waiting
for this slot", so the page now takes a service and a date and answers that.
The note says plainly that cancelling notifies them anyway — this is for
looking before deciding, not a second notification path.
Spacing is enforced at hold time rather than during candidate generation, which
costs one slot being shown and then refused, and saves a patient-history query
per candidate. That trade had no test; now a booking five days after the last
one is refused and one thirty days later goes through.
Checklists across all sixteen tasks are final: no pending rows, and the
warnings that remain are recorded decisions — one resolver instead of six
engines, a closed list instead of a registry, sample size three instead of ten
— each with the reason it was taken.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifteen rows across five tasks said the mechanism was there and the test was
not. Each of these is a case where being wrong would be silent.
- the price rows must add up to the final amount. The chain test checks every
number individually, which stays green if a new row is added and left out of
the total; this checks the relationship itself.
- a fixed deposit beats a percentage one, and neither can exceed the final
amount — charging a deposit larger than the bill puts the patient in debt
before the visit.
- an appointment booked without a service still gets an invoice. Slot mode has
no service, and without this the financial report is short a row with nothing
to say which.
- the four accuracy thresholds, each tested on its own boundary. One step off
and either everything is red (so nobody looks) or nothing is (so the report
is pointless). Includes a short-running service, since the deviation is
measured on its absolute value.
- all six policy templates build a policy that survives the normal validation,
simulation and activation path. A template is a shortcut, not a second road:
if one of them produced something the validator rejects, a user could create
a rule in one click that never works.
- simulation leaves nothing pending for a later flush in the same request. That
is what the finally-rollback-clear is for, and the failure would surface in
the next operation rather than in the sandbox.
The course controller was reading $this->credits without it being injected —
phpstan caught it; the package-shortfall path had no test yet and would have
500'd on the first course that had a package.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PUT /service-item/{uuid}/segments deletes and rewrites. deleteForService issues
a DQL DELETE that runs immediately, and three validations — duration, occupancy
and constraints — only ran afterwards, while building the new rows. A rejected
request therefore deleted the service's segments and saved nothing, and the
service silently fell back to "one continuous block": different duration,
different resources, on every future appointment, with a 422 as the only clue.
Validation now happens before the delete, and the delete plus rewrite are one
transaction. A test pins it: an unknown constraint is refused and the previous
two segments are still there afterwards.
While in there, the caps the task asked for and never got: 20 segments and 10
requirements per segment. The availability engine evaluates resource
combinations per segment per requirement, so the numbers protect the search
rather than the table. They are generous — no real service reaches them, but a
bad payload does.
The plan response now carries patient_facing_minutes. "Set aside 90 minutes"
is wrong for an appointment where 40 of them are waiting for anaesthetic to
take effect, and computing it once in the backend stops each client summing it
differently.
A condition on a fact the request never supplies still evaluates to false —
that part was right — but it now logs a warning naming the policy and listing
the facts that were available. A rule that hits that line every time is
effectively switched off, and nothing said so.
A new policy version can no longer start in the past: yesterday's appointments
were priced under the previous text, and their price trace points at the
version. Backdating makes that trace describe a rule that did not exist.
require_resource errors name the policy that demanded the role. Knowing a room
is missing does not tell an operator which of ten active rules to look at.
Six operators now have a test each. An operator that compares wrongly produces
a rule that always matches or never does, and neither raises anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 09 left this as its starred risk and deferred it to task 10, which then
shipped without it. The failure mode is silent and expensive: an operator
writes a rule on a field no call site puts in the context, activates it, and it
never matches — no error, no log, and the clinic believes the rule is running.
The test is structural rather than behavioural on purpose. Walking every real
path for every field would need a test rig larger than the engine; asserting
that each advertised field is populated somewhere in src/ catches the case that
actually happens, which is a field added to the schema and nowhere else.
Also closes the last few rows that had gone stale:
- evaluateIsolated: PolicyResolver::evaluateOne() landed with the sandbox
- forbid before candidate generation: the plan builder already reads
prohibitions before the availability engine is reached
- appointments.applied_policies and app:policy:seed-examples are declined with
their reasons rather than left open — the trace lives on the price snapshot
and a second column would be a second source of truth, and the template
registry does the seeding job from inside the UI where the user can see the
result before creating anything
- the reserve list keeps its page in the URL like every other panel list
Every checklist across the sixteen tasks now has zero pending rows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
The no-show records existed and drove the risk tag, but the patient's file
never showed the number behind it — the operator saw a tag with no evidence.
GET /patient/{uuid}/no-shows returns the count, the policy threshold and the
window, and the banner shows it only when the count is above zero: "0 no-shows"
on every healthy patient's file is an accusation nobody made.
The badge does not block anything and the docs say so. Blocking is an
eligibility policy from task 09 built on the same tag; a clinic that wants to
see the risk but still take a deposit must not have to switch the count off.
A test pins that a tagged patient still books.
Both report pages kept their range and branch in local state, so going back
from a resource lost the report and a shared link opened someone else's
default. They use useUrlState now, like every other list in the panel.
Three tests that were owed:
- the service-level cancellation policy beats the tenant one with no blending,
checked through the number that comes out rather than through the resolver
- a patient over the no-show threshold can still book
- occupied includes the waiting segment while active does not — if those two
came back equal the whole utilization report would be pointless
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 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>
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>
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>