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>
Anything that belongs to a service is defined on the service itself, so the
two tabs that managed selection groups and appointment segments come off the
service page.
Only the UI goes. SegmentTemplate is what makes a service occupy a room and a
device at the same time — it is the input to AppointmentPlanBuilder and the
reason the resource timeline has anything to draw — and a service without a
template already books through singleSegment(). Removing the model would
change booking; removing the tabs does not, which tests/Appointment confirms
at 314 green.
The active tab moved into the query string on the way past. That is what
makes the old ?tab=segments link land on the info tab instead of rendering
nothing, and it lets back and refresh return to the same tab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A resource carries its own working hours and holidays in the resource-first
model, so it belongs on the same settings page as a doctor's schedule rather
than on a page of its own. The page now has a scope switch — doctors or
resources — with the per-item tab bar below it, and both scopes reuse the
panels that already existed: ScheduleSection for a doctor, the working-hours
and exceptions panels for a resource. The selection lives in the query
string, so back and refresh return to the same tab.
The screenshot of the finished tab caught two real defects, both fixed here:
Dates in the resource panels and the holidays page read as year 57932.
formatDate already multiplies seconds by 1000, and five call sites passed
`x * 1000` on top of it. This predates the tab — the code was inherited from
the old calendar page — but it was invisible until a two-week preview was put
on screen.
The working-hours panel still told the user their hours were intersected with
the branch's. Branches are gone; the shift is the only source now.
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 skill's driver could not log in any more: its default credentials were
a user the scenario seeder wiped, so every command died on ERR_AUTH_005
before taking a single screenshot. Defaults now point at a user the seeder
actually creates, and the failure message says how to rebuild the users.
A page was also being judged on one screenshot. Dark mode and compact
density are real settings in this panel and mobile is where an RTL,
table-heavy admin breaks, so `variants` now captures all four and the theme
is written to the ui store rather than only stamped on the element — the
attribute alone is overwritten at hydrate. Narrow shots enable device
metrics, without which pointer:coarse media queries never fire and the
44px touch targets stay invisible.
Every shot now probes the live DOM for the things no grep can see:
horizontal overflow, nameless icon buttons, unlabelled fields, controls
under 32px. The static audit gained Gregorian dates, native date inputs,
icon buttons with no aria-label, and .seg without an on/active class.
`ds` prints the tokens and the shared components with their props, so a
redesign starts from what exists instead of inventing a second Modal.
Also corrected a stale claim: the suite has no pre-broken tests — it is
100 files / 660 passing.
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 desktop sidebar kept its own copy of the item list, so the entries added
to the mobile settings menu — Resources, Categories, Branches, Holidays,
Price lists — never appeared in it. Adding a settings page meant editing two
files, and forgetting one was silent.
settingsMenu.ts is now the single source; the sidebar derives from it with
its two deliberate differences stated in code: no "manage doctor" entry, and
a disabled "security" row at the end. Labels follow the mobile list, which
uses the correct zero-width joiner spelling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resources, branches, price lists, holidays and the new categories page sat
in the settings menu but rendered bare, so clicking one made the settings
sidebar disappear — the subscription page was the only one that kept it.
Eleven pages now wrap in SettingsLayout with the key of the menu entry they
belong to, and the four resource pages (list, types, skills, pools) share
one menu entry plus a sub-nav between them, rather than four entries that
would make the menu a third longer without making anything clearer.
.seg accepts `a` as well as `button`, and treats `active` as an alias of
`on`. Both were needed: cross-page tabs must be real links, and the pages
already using `active` (service detail, clinic appointment settings) had no
visible highlight at all.
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>
In the resource-first model a resource is the unit of capacity, so its
working hours, holidays, services, skills and categories belong to it — not
scattered across a list page's modals plus a separate calendar page.
/admin/resources/{uuid} now carries six tabs and the active tab lives in the
query string, so back and refresh land on the same view. The old
/calendar URL redirects to ?tab=hours instead of 404ing.
The skills and services modal bodies became panels the tab renders directly;
the modals are now thin wrappers, so the list page keeps working unchanged
and there is still one implementation of each editor.
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>
doctrine:diff kept re-proposing CREATE TABLE messenger_messages, and with it in
place app:seed-scenarios --reset failed on a clean database: the doctrine
transport creates that table on boot, before migrations run, so the CREATE hit
a table that already existed. It is Symfony's table, not ours.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/architecture/resource-first-model.md describes the shape: the three
entities, why an option is a ServiceItem rather than a fourth table, the
four-level resolution chain, the two conditions on the eligibility filter and
what each of them prevented, and why containment is a graph beside the display
tree rather than the tree itself.
docs/api/resource.md gains both offering endpoints with the response captured
from a real call, including a row where the price comes from the branch and one
where it comes from the resource — the two cases the *_source fields exist for.
docs/api/appointment.md documents resource_uuid, the doctor inference, and the
nullable resource/service_option in the response.
The checklists for tasks 9 to 14 keep their rows but open with a banner saying
the task was removed, when, by whose decision, and which commit to revert. They
are history now; deleting them would erase the record of work that shipped and
was then withdrawn.
Verified end to end: 1304 tests, slot-mode-frozen green, phpstan at 14, tsc
clean, 648 panel tests, and app:seed-scenarios --reset builds all three
environments.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A "سرویسها" action on each resource row opens a modal listing what that
resource performs, with its own duration and price. It follows the skills modal
exactly — same PUT-replaces-everything contract, same components, no new page
and no new route.
Leaving a cell empty means inherit, so the effective value is shown as the
placeholder along with where it came from: "40 — service default", "1,800,000
toman — branch". Without that the user cannot tell an unset field from a zero,
which is the one thing this screen has to communicate.
Two backend adjustments came out of wiring it up:
- the offering filter in findEligible is now scoped to the requirement's
resource type. Registering lasers for a service was making the room in the
same plan ineligible and breaking the whole booking — "who performs this" is
about the performing role, not about rooms and support resources. The seeder
caught this immediately.
- the scenario seeder now creates offerings and resource categories, so the
demo data exercises this model instead of leaving every resource empty.
Three vitest tests: inherited value with its source, saving an override, and
clearing back to inheritance. Verified in the browser at 1440 dark, 1440
compact and 390 mobile — the last with no horizontal scroll.
Panel suite 648 green across 98 files, tsc clean, encore build succeeds.
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>
The product owner scoped the booking model down to resources, services and
service options; everything else goes. Deleted, with their tables, tests, panel
pages and API routes:
src/Policy 27 files policy engine, simulation, templates
src/Package 13 files packages and the credit ledger
src/Course 14 files treatment protocols and courses
src/Cancellation + src/Waitlist 19 files penalties, no-shows, waitlist
src/Report + src/Shared/Event events outbox and the two reports
The surviving code had hooks into all of them, so this is not only deletion:
- AppointmentPlanBuilder loses the timing and resource policy passes, and the
requirement-from-policy builder that had no other caller (449 → 379 lines)
- PricingEngine loses policy discounts and package coverage; PriceQuote loses
its package fields and quote() its patient argument, which existed only to
find a usable package
- BookingService no longer consumes credit, unlinks course sessions or emits
events; HoldService and ResourceBlockController stop emitting too
- ServiceSelectionValidator keeps groups and relations, drops the policy layer
- the appointment loses its course_session link
Cancelling an appointment still works and still records who and why — it moves
through PATCH /api/v1/appointment/{uuid}/status. What it no longer does is
compute a penalty, so the panel dialog is a plain confirm now. That is the
visible cost of this change and it is intended, not an oversight.
The drop migration uses DROP TABLE IF EXISTS with foreign-key checks off: a
half-applied earlier run left some tables already gone, and a migration that
only works from a pristine schema is a trap on any environment that was
interrupted. messenger_messages is deliberately left alone — it is Symfony's,
not ours.
Suite green at 1258 tests, slot-mode-frozen green, phpstan back to its 14-error
baseline after removing two now-unused members it caught, tsc clean, and
app:seed-scenarios still builds all three environments.
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.
The first version of the seeder only filled the old skeleton: doctors, clinics,
services, appointments written straight to the table. None of the sixteen tasks
under docs/new_feture had any data, so nothing they built could be tried.
BookingEngineSeeder now seeds, per environment:
- catalog v2: a category tree, an item group with a 1..2 selection range, an
incompatible pair, and a per-branch price override
- resources: a skill with levels, a resource pool with priorities, and a
maintenance window next week
- a three-segment plan on the flagship service — numbing (room exclusive), wait
(room passive, nobody else held), laser (room + device) — which is the whole
point of the plan model and cannot be seen with single-segment services
- an active price list, and a price snapshot per booked appointment
- six policies, one per category, each with a condition and an effect
- a package with a consumed session in the ledger, and a treatment protocol with
per-session parameters plus an active course carrying its six sessions
- a general and a per-service cancellation policy, no-show records, waitlist
entries
Two appointments per environment are booked through the real path —
AppointmentPlanBuilder, AvailabilityEngine, HoldService, BookingService — so
segments, resource occupancy and the domain-event outbox are populated by the
code that will run in production rather than by INSERTs.
Two defects in the seeding surfaced and are fixed here:
- SegmentRequirement is the owning side, so persisting one leaves the template's
in-memory collection empty. The plan built later in the same process saw
segments that needed nothing, and the bookings occupied the device but never
the room — while the same service was correct over HTTP, where the entity is
read fresh. The collection is now kept in step.
- passing the service as its own selected item produced a different plan than
the booking flow builds.
A course with no sessions also reported "everything is scheduled"; sessions are
now created from the protocol steps.
Verified over HTTP: segments come back as 5/30/20, utilization reports 110
minutes on the room and 90 on the laser, the course suggests session 1 with
three slots, and the six policies list one per category.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Manual testing had no environment to test in: the demo seeder builds volume
(500 doctors, 20k appointments via raw INSERT) for the representation module,
which is the wrong shape for walking through a scenario end to end.
app:seed-scenarios builds three environments that each work from login to
booking:
1. an independent doctor, service mode, with services, schedule, insurance,
patients and appointments
2. a doctor who also owns a clinic, with three more doctors inside it, a
slot/service mix, two laser devices and two rooms, and laser services that
genuinely require a laser
3. a clinic whose owner is not a doctor, with all three booking modes live
(slot, service and resource), five devices, and the same full data set
Everything goes through entities and the real services rather than raw SQL, so
tenant pairs, the unique active-slot key and the insurance rules hold. The
status machine is walked step by step (completed only via confirmed) instead of
writing a status the application could never produce.
--reset drops the schema, re-runs migrations and seeds base data in one go.
Three things it has to handle, each found by it breaking:
- representations must exist before cities, because cities.json references them
by id and the category importer validates that
- migrations run mid-process invalidate the EntityManager's connection, so the
manager is taken from the registry and reset afterwards
- a sub-command's --no-interaction in ArrayInput is not enough; without
setInteractive(false) the migration waits forever for a confirmation
It also writes site_config.altcha_enabled = '0'. On a freshly migrated database
the captcha defaults to on and nobody can log in at all — panel or site.
Verified against the running app, not just the database: booking-locations
reports the right mode per doctor, service slots respect the buffer, the
slot-mode doctor returns a session with 15 slots, the resource-mode doctor
returns 40 options each with a real device assignment, a patient booked a
service appointment through the public endpoint, and the admin panel renders
the seeded day for both clinic owners.
TEST_USERS.md is rewritten: every account it described was gone after the wipe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 product owner reverted the dark-mode work on nobat724_front, so six rows in
task-00b that either claimed dark mode was verified or listed it as debt no
longer describe reality. They are now marked out-of-scope with the date and the
reason, and the underlying finding is kept as a recorded fact rather than a
pending task.
Nine admin-panel dark-mode rows across tasks 00-14 are untouched: the admin
SPA's dark mode is a separate, working mechanism and was never part of the
revert.
Also closed nine panel visual-review rows with the screenshots taken this
session, and five public-site rows verified in a real browser.
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>
The wiring is fixed and browser-verified, but components/appointment/ has no
dark: utilities at all, so the booking flow still renders identically in either
theme. That is design work, and the row says so rather than claiming done.
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>
The visual pass over the older screens found one inconsistency worth fixing:
the service catalog opened with a hand-rolled bold line and a button. No back
button, no description — the one page in the panel that does not say what it is
or how to leave it. It uses PageHeader now, like every other page.
Everything else on the task 01-04 screens held up under dark mode and compact
density: branches, resource types, skills, pools and the catalog all read from
tokens and none of them broke. The only remaining mobile findings are the
shared shell's Latin phone number and one small tap target, both of which
predate this work and appear on the dashboard too.
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>
Five places where the data existed and the screen did not use it.
Booking a whole course had no button because it needs a doctor and the course
does not carry one — each session can be with a different doctor. The page now
asks for the doctor the same way the resource booking page does, and the button
explains that it is all-or-nothing before it is pressed.
A course whose package does not cover the remaining sessions is still valid —
the rest is simply charged normally — but nobody was told. The course response
carries package_balance and the shortfall, and the page warns. Before session
six, not during it.
The credit ledger already returned who recorded a row and which appointment it
belonged to, and showed neither. An adjustable ledger without the name of the
person who adjusted it is half an audit trail.
Version history printed a JSON blob of each version's effects, which does not
answer the question anyone actually has: what changed? It now diffs each
version against the previous one, field by field, and says so plainly when a
version changed nothing meaningful.
A resource with no calendar showed "—" for utilization. Null means undefined,
not zero, and the next step is always the same: set up the calendar. It is a
link now. The report range also accepts a custom from/to, kept in the URL like
the rest.
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>