00349cdb44bfca084fc65fa3f17593e0ea0bb5f0
418
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
df07d00adc |
docs(checklists): mark the public-site dark-mode rows out of scope
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> |
||
|
|
e5b74ebab4 |
docs: settle every remaining row, and add the third occupancy mode
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> |
||
|
|
e530ab5678 |
docs: record the dark-mode finding on the public checklist
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> |
||
|
|
2db7a500e6 |
feat(reports): restore the documented sample threshold, and draw the chart
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> |
||
|
|
f2600f9922 |
refactor(policy): build the registries and six engines the architecture asked for
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> |
||
|
|
ca2f9b8652 |
feat(admin): build the last two screens, and pin spacing with a test
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> |
||
|
|
5c754244f2 |
feat(admin): finish the screens that were stopping one step short
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> |
||
|
|
fe48b10fb5 |
fix(plan): stop the segment replace from destroying segments when it rejects
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>
|
||
|
|
d98a0396a4 |
test(policy): fail if the schema advertises a field nothing ever supplies
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> |
||
|
|
b3c331f0cb |
perf(reports): read every resource's calendar in one batch, and close the owed tests
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> |
||
|
|
27c0b8f4f6 |
feat(patients): surface the no-show count, and put the report filters in the URL
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>
|
||
|
|
e9e61adfee |
feat(course): show how the course is actually going, not just how it was planned
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> |
||
|
|
000cf70761 |
feat(resources): warn before switching a resource off, and pick dates in Jalali
The deactivation warning was blocked on task 07: there was no way to count "the
appointments on this resource" until occupancy rows linked the two. They do
now, so GET /resource/{uuid} returns upcoming_appointments. It stays off the
list endpoint, where it would be one count query per row.
It is a warning, not a block, and the wording says so: switching a resource off
does not cancel anything, it only removes the resource from future searches.
The panel shows it the moment the "active" box is unticked.
The calendar's exception range still used <input type="date">, which is
Gregorian. Operators say dates in Jalali, and the mental conversion is exactly
where an exception gets recorded a day off. PersianDateInput takes the same
YYYY-MM-DD string, so this is a drop-in swap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
47a40e2021 |
feat(plan): build the plan from the selected items too, so mergeable finally means something
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> |
||
|
|
4bba322b8e |
feat(waitlist): make the day-part preference, the conversion and the expiry real
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> |
||
|
|
635bf3d2a8 |
fix(admin): correct two design-system mismatches found by looking at the pages
Screenshotting the pages under dark mode and compact density (rather than trusting that design tokens were enough) turned up two mistakes repeated across every page this feature set added: - `.card` carries only the surface, border and radius — padding comes from the separate `.card-pad`. Fifteen cards were rendering with their content flush against the edges. - `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a control in it produced a joined addon rather than a label above its field. `.field-block` is the label-above layout, and thirty-seven wrappers now use it. Both were invisible to type-checking and to the tests, which is exactly why the visual pass was worth running. Numbers in the new UI now go through formatNumber so they render as Persian digits, and the utilization page's header no longer repeats the sentence that appears under its filters verbatim. The QA driver gained a `--ui` flag: theme and density live in localStorage['clinicpro-ui'], so without seeding them dark mode and compact density cannot be screenshotted at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4049daf071 |
feat: close the last four domain events, and the panel paths they describe
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>
|
||
|
|
a8020e3c20 |
docs: reconcile checklists with what the last passes actually built
Rows closed by the resource-strategy and blocking work: task 06's picker interface and four strategies, task 07's ad-hoc blocking and 409 recovery, and task 12's same-as-previous preference, which had been blocked on task 06's missing strategies since it was written. Tasks 13 and 14 both carried the flaky-suite caveat against their final review; that flake has a diagnosed cause and a fix, so both now say so instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0127b463a6 |
feat(resource): ad-hoc blocking, 409 recovery, and the rest of the flake
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> |
||
|
|
aa6ea45a57 |
feat(availability): resource ordering strategies, and a real fix for the flaky suite
Strategies (task 06 debt, task 12 dependency) - ResourcePicker orders candidates; it deliberately does not choose. Only the engine knows which resource actually fits this slot and which was already taken by another role, and a strategy that picked would have to duplicate both checks - Four implementations behind a tagged iterator: first_available (name order, the previous behaviour and still the default because it is predictable), least_gap, least_loaded, same_as_previous - least_gap and least_loaded are deliberate opposites and both are correct; choosing between them is a business decision, so it lives in settings - same_as_previous lifts a course's preferred resource to the front and keeps everyone else behind it. A preference, not a filter: forcing the same operator would make the patient wait two weeks, which is worse than a different operator - Availability accepts course_uuid to supply that preference, closing the dependency task 12 recorded against task 06 - An unknown strategy falls back at search time but is rejected at save time. Stale settings must not stop bookings; a user typing a wrong value must not believe it took effect Test suite flake createUser() retries on a mobile-number collision — db_test is never reset and holds tens of thousands of users, so the random draw does collide. The failed INSERT closes the EntityManager, and the retry asked the container for it again, which hands back the *same closed instance*. So the retry threw, and every later test in that process inherited a dead manager. That is the intermittent "EntityManager is closed" on an unrelated, always-different test that made roughly half of full runs red and never reproduced in a subset. Resetting the registry gives a live manager back. UserCollisionRetryTest pins it by closing the manager on purpose. Two consecutive full runs are green: 1334 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0074162bb1 |
feat(admin): resource-mode booking flow with a hold countdown
The engine from tasks 06 and 07 could find slots and hold them, but nothing in the panel could actually book one. - Search, hold, confirm stay three separate steps because they are three separate states: between seeing a slot and taking it the seat is still open, and between taking and confirming there is a deadline - HoldCountdown reads the server's expires_at rather than starting its own timer at render: browser clock skew and network latency both cost seconds, and those seconds are exactly where a hold is lost. It turns urgent under a minute and tells the parent the moment it lapses - Per-role resource swap offers only the resources the engine returned for that same slot. Listing every resource in the branch would let an operator pick one that was never free and collect a 409 - An empty result is not an error: the reason code renders as a sentence saying what to change - Confirm requires a doctor and stays disabled until one is chosen — the endpoint rejects it anyway, and finding that out after the hold clock has been running is the wrong time Reached from the appointments page as a separate action rather than folded into the existing form: its search comes from the intersection of resource calendars, not from one doctor's slots, and merging the two would confuse both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4bca659939 |
feat(admin): price lists and the appointment invoice card
Task 08's pricing chain was reachable only through the API, so a clinic could not define a price list or see what a booked appointment was actually charged. Price lists - Draft / active / expired are shown as three states because they mean three different things operationally: a draft has no effect on today's price at all - Activation is a separate action rather than a checkbox in the form, matching the backend rule that creating a list must not change anything - "Copy" seeds a new list from an existing one starting the day the old one ends, since most lists are last quarter's with a few numbers moved - "All branches" is an explicit option, not an empty field Invoice card - Renders the recorded chain down to the final amount, hiding zero rows so the card stays readable - A missing invoice renders as a normal state, not an error: an appointment that was never confirmed has no invoice - Says outright that the numbers are from the appointment's own date and later tariff changes do not move them — otherwise someone who edited a price yesterday reads today's older number as a bug Also corrects task 08's checklist: its test section carried a copy-pasted "no UI was built" note against rows whose tests have existed since the task shipped. Replaced with the real test names and the two that genuinely are not covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d56c41c87e |
feat(admin): resource booking mode with a readiness guard
Task 06's engine could only be switched on through the API, and nothing checked whether the environment was ready for it. Since the mode choice is irreversible, picking it with no resources defined would lock a clinic into a state where no appointment is ever computable. Backend now refuses that: resource mode requires at least one active resource, with a message that says what to define first. Same shape as the existing service-mode guard, applied on both save paths. The panel shows the same conditions as a ✓/✗ list before the choice is made, each unmet one linking to where it gets fixed — a 422 after an irreversible decision is the wrong place to learn about a prerequisite. Also adds the search step (minimum 5 minutes) and extends the existing mode cards to three rather than building a parallel component. No strategy picker: task 06 never built the strategies, and an empty menu reads worse than an absent one. GET /api/v1/service-items now returns has_segments, computed with one aggregate query for the whole list rather than one per service. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
26a8e53b34 |
feat(admin): catalog groups and appointment segments editors
Tasks 04 and 05 shipped working engines that a clinic could only reach through the API. Both now have the panel that makes them usable. Groups tab - Inline min/max per group, saved on blur, with the meaning of an empty maximum spelled out next to the field rather than left as folklore - Incompatible / prerequisite rows; the prerequisite-cycle 422 surfaces the server's own message, which is more precise than anything generic - A live preview that calls the same service-selection/validate the public site calls, debounced 400ms. Two separate calculations would eventually show the operator and the patient different numbers - The breakdown table shows which item was counted as the anchor and which as additional, so a surprising total explains itself Segments tab - Sequence, duration source, patient-present and mergeable per segment, plus resource requirements with an explanation attached to each occupancy mode - A timeline bar whose widths are proportional to duration, with segments the patient is absent for drawn faded. That contrast is the whole point of task 05: the waiting segment holds the room but frees the operator - "No eligible resource" renders with a link to add one — an error with no route forward is a dead end Task 05's checklist had been left on "not started" this whole time even though its code shipped with the task; it is now filled in against reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3c43955800 |
feat(events): domain event outbox and the two reports that close the loop
Tasks 07 through 13 each changed something the rest of the system might want to know about, with no contract for saying so. And task 05 shipped a powerful segment editor with no feedback on whether a clinic defined its segments right. Events - A closed list of names, because a consumer branches on the string and a one-letter typo would produce an event nobody hears and no error either - Payloads carry uuids and scalars only; non-scalars are dropped, not serialised, so a consumer always fetches fresh rather than reading a stale detached entity - record() deliberately does not flush: the event row commits with the change it describes, so a rolled-back transaction leaves no event behind. A test pins exactly that - app:events:publish drains the outbox; five failed attempts park a row with its error rather than deleting it, because a silently dropped event is a loss with no trace. app:events:prune only ever removes published rows Reports - Resource utilisation separates available, occupied and active minutes. The gap between occupied and active is what exposes a bad segment definition, and available is multiplied by capacity so a three-chair room does not read as permanently over 100% - A resource with no calendar reports utilization: null, not zero — dividing by zero means something different from being idle - Plan accuracy compares planned against actual duration per service and flags both directions: running short wastes capacity that could have been sold. Its row links straight to editing that service's segments, because a report with no route to a fix does not get read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fba1555f22 |
feat(cancellation): cancellation policy, no-show tracking and a waitlist
Cancelling worked but had no policy behind it: no window, no penalty, nothing happened to the deposit, and the no_show status had no effect at all. Two rules that are expensive to get wrong, and both are load-bearing: - The clinic cancelling its own appointment is never charged. That check is the first line of the calculation, not somewhere in the middle, so a later refactor cannot reorder it into charging patients for the clinic's decision. - A penalty never exceeds what was actually paid. Anything above that is a debt, and debt belongs to billing, not to cancellation. An unpaid appointment is charged nothing and the response says why. The default is no penalty at all — a penalising default would have made every patient with a near appointment liable the moment this deployed. No-shows are rows, not a counter on the patient: a counter loses which appointment and when, which makes the 12-month window impossible. Crossing the threshold adds an existing TenantTag; it never blocks the patient, because blocking is an eligibility policy (task 09) written on top of that same tag. Waitlist notifies up to ten matching people and the first to book wins. An exclusive queue reads fairer but means a freed slot sits locked for half an hour while someone ignores their phone — so the SMS says so explicitly instead. Insufficient wallet balance does not fail the cancellation: the slot is freed either way. A slot should not be held hostage to money. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fc504f4415 |
feat(course): treatment courses with protocol-driven session planning
Laser is six to eight sessions; the previous design only knew single appointments, which is the exception rather than the rule. - CourseProtocol per service: session count and three distinct spacings — min is the earliest that is clinically allowed, ideal is best, max is where the course starts losing its effect - Starting a course creates every session up front as `planned` and copies the protocol's numbers and per-session params, so changing the protocol tomorrow leaves a running course alone - Suggestions anchor on the last *completed* session, not the course start: when session 2 slips, session 3 moves with it - Slots are ranked by distance from ideal, not by earliest available — day 21 is worse than day 27 when 28 is the target - book-all is all-or-nothing inside one transaction, with a moving anchor and a 90-day horizon; sessions past the horizon stay planned and are reported, not treated as failures - The effective minimum is the stricter of the protocol and the task-09 spacing policy, so a clinic rule never fights the protocol - Cancelling one session returns only that session to planned; abandoning a course does not cancel its appointments, which stays an explicit decision One active course per (patient, service) via active_course_key, the same partial-uniqueness trick as Appointment::activeSlotKey. Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the patient record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ca9648732d |
feat(package): session packages backed by a credit ledger
"Six laser sessions" is the common case in an aesthetics clinic: the patient pays once and books the sessions later. Credit is a ledger, not a counter. No table has a remaining/used_count column and a schema test enforces that — the balance is always SUM(delta) over append-only rows, so every number a patient sees has a full history behind it. Corrections are new rows, never edits. - purchase / consume / refund / adjustment / expiry, each with a reason, an author and the appointment it belongs to - consume happens in confirm(), never in quote(): if the preview consumed, a page refresh would cost the patient a session - cancelling adds a refund row; the consume row stays - FIFO across a patient's packages — the oldest is closest to expiring - an empty package is not an error, it just does not apply and the patient pays - adjust/expire need a doctor or clinic role, and adjust always needs a reason - app:package:expire writes the closing row so "where did my 3 sessions go?" always has an answer Consume takes a pessimistic lock on the one package row. That is the opposite of task 07's slot buckets, and docs/api/package.md carries the table explaining why, so nobody unifies them later. Idempotency checks for an existing consume row before inserting rather than catching the unique violation: in Doctrine that exception closes the EntityManager and burns the rest of the request. The unique key stays as the last line of defence. Admin: PackagesPage, a packages tab on the patient record, and a ledger page whose running-balance column shows where the final number came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bcfa87bfad |
feat(policy): rule builder and mandatory dry-run sandbox
Task 09 shipped a powerful API that a non-technical clinic owner could not safely use. This closes that gap: activation now requires having seen what the rule actually does. - PolicySimulator runs a policy against real past appointments and writes nothing: evaluation works on facts (never entities), the whole run sits in a transaction rolled back and cleared in `finally`, and a test counts rows in five sensitive tables before and after - activate() now demands a simulation of the *same version* — a report for version 1 does not unlock version 2 - PolicyTemplateRegistry: six ready-made rules, so the common case never touches a raw condition - Severity from the affected ratio; 0% is a warning too, since a rule that changes nothing usually has a condition that never matches - An empty clinic still succeeds with a warning, otherwise a new clinic could never activate anything Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a field that exists only in the schema shows up with no frontend change, and that operators are filtered per field type. The schema response now carries per-field metadata (label, type, meaningful operators) so the form has one source of truth instead of two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
584ea4067f |
feat(policy): six-category policy engine wired into the booking flow
Rules become data instead of code: a clinic can say "laser under 18 requires parental consent" without a deploy. Engine - Policy / PolicyVersionLog entities, closed field/operator/effect lists per category (PolicySchema), condition validation at write time - PolicyResolver: priority -> specificity -> age, combining effects by veto / max / sum / union - A missing fact fails its clause instead of silently passing it - Policies are drafts until activated, and are versioned rather than edited Wiring - selection -> ServiceSelectionValidator - eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time - resource + timing -> AppointmentPlanBuilder, including template-less services - pricing -> PricingEngine, alongside (not replacing) the manual discount The condition column is named condition_json: `condition` is a MariaDB keyword and broke every INSERT. Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a clinic with no policies sees byte-identical output to task 08. Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
34b07421bd |
feat(pricing): date-ranged price lists and immutable appointment invoices
Section 12 and the fifth closing rule: changing a price never changes an already-booked appointment. The pricing chain already existed and worked. Two things were missing. Tariff only carries a year, so a rate change starting in Mehr could not be expressed — PriceList now takes an explicit date range and Tariff remains the layer beneath it. And an appointment stored a single number, so after a price change or a discount nobody could say what those 2,400,000 rials were made of. Price resolution walks four layers per service and takes the first hit: branch override, then the covering price list, then the yearly tariff, then the service's own price. The last one is the guarantee that a date no list covers still returns a price rather than zero or an exception. breakdown.sources reports which layer answered, so a surprising number can be traced instead of guessed at. Two calculation decisions worth stating. Tax is computed on the patient's share, not the gross — a patient does not pay tax on the portion the insurer covers. And a discount larger than the amount floors the total at zero rather than going negative, because a negative balance would mean the clinic owes the patient money, which nothing downstream is built to mean. A branch-specific list deliberately does not count as overlapping a general one; it takes precedence instead. Treating them as a conflict would have made per-branch exceptions impossible to express. Lists have no effect until activated, so drafting next quarter's prices cannot disturb today's. PriceSnapshot has no setters and a unique key on appointment_id: a snapshot that can be edited is not a snapshot, and two invoices for one appointment would be two truths. Corrections are a new row plus voiding the old one. Invoices are written during confirm with the prices of that moment — computing later would let a rate change between booking and invoicing produce a different number, which is exactly what rule five forbids. 12 tests. The one that matters is testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange: book, double the service price, watch quote return the new number while the appointment's invoice returns the old one. Without it rule five is only a claim. 1220 tests / 3551 assertions. phpstan back at its 14-error baseline. Frozen slot contract green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4395eea56e |
feat(booking): multi-resource holds and confirmation with a database-level guarantee
Section 11 and the third closing rule of the design document: preventing a double booking is the database's job, not the code's. Any "is it free?" check in PHP has a race window between the read and the write — two concurrent requests both see free and both write. MariaDB has no range EXCLUDE constraint, so every occupied interval is broken into fixed five-minute buckets under UNIQUE(resource_id, bucket_at, seat). The code only INSERTs; a rejection from the database *is* the answer. `seat` carries capacity: a three-bed room has seats 0..2, allocation walks upward on each collision, and the fourth concurrent hold finds nowhere to sit. Counting capacity in PHP would have rebuilt the very race this removes. Buckets are written through DBAL rather than the ORM on purpose: a unique violation raised inside flush() closes the EntityManager, and the next seat attempt would then fail with "EntityManager is closed", hiding the real outcome. Occupancy is one row per (segment × resource). The reference test asserts the payoff directly: for a 55-minute appointment of numbing / waiting / laser, the room gets three rows and the operator only two — the operator holds nothing during the wait and stays bookable for someone else. A partial hold never survives. If the second resource has no room, the first is released and the hold itself removed; otherwise a resource stays locked for an appointment that will never exist. Confirming does not re-reserve anything — the seats were taken at hold time and only the label changes. Re-reserving on confirm would reopen the race the hold closed. Cancelling marks rows `released` instead of deleting them, because the history of which resource was busy when is the input to the utilisation reports; the uniqueness buckets *are* deleted, or that interval would stay locked forever. Expired holds are released by the existing scheduler rather than a new one. That exposed a bug in my own change: the flush guard used $count, which now includes released holds, so reset([]) could pass false to save(). It is guarded on $expired. The appointment itself is still built with the existing constructor, so active_slot_key, events and the payment path behave exactly as before — the multi-resource occupancy sits beside them, not instead of them. 12 tests. Two matter most: the second hold on the same resource and interval getting 409, and a test that writes a duplicate bucket row over a *separate connection* and expects the unique-key violation — if that one ever passes silently, the guarantee had moved back into the code. 1208 tests / 3495 assertions. phpstan at its 14-error baseline. Frozen slot contract green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
24534ec483 |
feat(availability): multi-resource availability engine
Section 10 of the design document, and the payoff for tasks 01–05. The engine slides a multi-segment plan across resource calendars and answers which times are actually possible, with a suggested resource for each role. Until now the only conflict the system checked was the doctor's; rooms, devices and operators did not exist. Allocation is per *role*, not per segment, and that is what returns the wasted capacity. An operator with no requirement during "waiting for the cream" is simply not examined for those minutes, so another patient can use them. The reference test encodes exactly that: patient A holds 10:00–11:00 while the operator is only busy 10:00–10:05 and 10:35–11:00, and patient B is offered a slot inside the gap with the second room assigned. The spec says the task is not verified without that scenario. One resource is chosen for every segment that needs its role, not independently per segment — otherwise the operator in segment 1 and segment 3 could be two different people and the patient would change hands mid-treatment. Occupancy is stored one row per (segment × resource) rather than one per appointment. The granularity is the whole point; a row per appointment would re-create the single-interval model the design rejects. Reserved intervals are widened by each resource's setup/cleanup, because the resource genuinely is not available then. booking_mode gains a third value, resource, alongside slot and service. It is purely additive: the default stays slot, no environment moves on its own, and a location that has not opted in keeps the untouched legacy path. The frozen slot-mode contract stays green. Performance is a test, not a hope: 30 days, 20 resources and 500 existing bookings complete well inside the 500ms budget. Every input is read once and the rest is in memory — no query inside the day or candidate loop — and candidates are generated only from the free windows of the scarcest role, which turns tens of thousands of candidates into a few hundred. An empty result is not an error and not a 404: it carries reason: "no_capacity_in_range" so the caller does not have to infer meaning from emptiness. Also fixed a genuinely intermittent test defect: NumericFieldNormalizerTest padded a random number with the three-byte Persian "۰" using byte-based str_pad, producing broken UTF-8 whenever the number was short. It failed roughly at random. The improved assertion message added earlier is what identified it immediately. 1196 tests / 3414 assertions. phpstan at its 14-error baseline. Resource-picking strategies, the availability cache and the settings UI are recorded as outstanding in the checklist with reasons — the cache in particular would be premature while the performance test passes comfortably without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
22c89fbae4 |
feat(plan): multi-segment appointments with per-segment resource requirements
Section 7 of the design document, and the reason the whole resource layer exists. A laser session is not one block: numbing cream (5 min, room + operator), waiting for it to work (30 min, room only), the laser itself (20 min, room + operator + device), aftercare (5 min, room + operator). Under the single-interval model the operator is locked for all 60 minutes while actually working 30 — half the capacity thrown away. AppointmentPlanBuilder turns (service, selected items, branch, patient) into a plan: segments with offsets, durations and resource requirements. It deliberately assigns no absolute time and no specific resource — that is the next task. This only produces the *shape* of the appointment. Segment duration comes from one of two sources. A fixed segment carries its own number; an item-driven one gets its duration from task 04's DurationCalculator, so "the laser itself" grows with two treated areas while "waiting for the cream" does not. One number could not have expressed that. Three contracts worth stating: - A service with no segment templates falls back to a single continuous segment requiring the doctor resource — exactly today's behaviour. Without it every existing service would have become unplannable overnight. - A segment with no requirements is valid: "waiting at home" consumes time but occupies nothing. - same_gender_as_patient with an unknown patient gender is a 422, not a silently dropped requirement. Dropping it quietly would route the patient to a resource the clinic said must not serve them. When no resource qualifies, the error names the role, the skill and the branch — "no female operator with the skill «Alexandrite laser» is available at «Central»" — rather than an empty result the caller has to interpret (section 10). occupancy_offset carries each requirement's setup/cleanup minutes for the availability engine. It is taken as the maximum across candidates, because the builder does not yet know which resource will be picked and under-reserving means the next appointment lands on top of the cleanup. 11 tests covering the document's reference example (offsets 0/5/35/55, total 60), item-driven scaling, the no-template fallback, all three gender-constraint outcomes, merging and both caps. 1186 tests overall. phpstan back at its 14-error baseline; slot-mode frozen contract green. The admin segments page is not built; the checklist records it with a target. The backend and preview endpoint are complete and consumable without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b1b06c1b36 |
feat(catalog): dual durations, item groups, relations and branch overrides
Section 5 of the design document rejects summing service durations. "Face + bikini" is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not happen twice. Seven wasted minutes times twenty appointments a day is an hour of capacity lost daily, and AppointmentController was doing exactly that plain sum. Each item now carries a solo duration and an additional duration. One item counts at its solo duration and the rest at their additional; the anchor is the item with the *largest* solo duration rather than the first one selected. Anchoring on selection order would have let the same basket cost different amounts depending on click order, so a patient could buy a shorter appointment by reordering. Largest-first is also conservative: no combination is ever under-estimated, and under-estimating pushes the next appointment on top of this one. additional_duration_minutes stays NULL by default and the entity reads NULL as "same as solo", so every existing service keeps behaving exactly as before — the 236 appointment-domain tests pass unchanged. The old duration_minutes column is kept and written in step rather than renamed, because other consumers still read it. ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line change task 00 predicted when it deliberately preserved the naive sum. Selection rules are data, not policy: min/max per group is a number, and "bikini does not combine with full body" is a relation. Putting either in a rules engine means several rules per service and nobody able to explain a rejection. Validation returns *all* errors at once rather than the first, since a user with three problems should not make three round trips. Prerequisite cycles are rejected at write time — storing both "A requires B" and "B requires A" would make every selection permanently invalid. Named CatalogCategory, not ServiceCategory: that name is already an insurance enum (outpatient/inpatient) living on ServiceItem itself, so the two would have collided in the same file's imports. Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key that may not exist, which warns instead of yielding null. 1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without this change (verified by stashing). Slot-mode frozen contract green. The admin UI tab for groups and relations is not built; the checklist records it as outstanding with a target. The backend is complete and POST /service-selection/validate is consumable without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4d722830e3 |
feat(resource): calendar UI, holiday admin, backfill and interval algebra
Completes task 03. The resource calendar page edits weekly shifts, records leave and
maintenance, and previews two weeks of availability with a Persian reason for every
empty day — showing the raw server key ("outside_branch_hours") to a user would have
been a meaningless message. The preview is labelled raw on the page itself, because
booked appointments are not subtracted yet and mistaking it for bookable time leads
to overbooking.
The interval algebra moved to src/Shared/Time/TimeInterval.php with twelve unit
tests: tasks 05 and 06 need the same union/intersect/subtract, and a second
implementation is how two subtly different definitions of "overlap" get born. The
half-open [start, end) contract is what makes a shift ending at 13:00 and one
starting at 13:00 not overlap.
AvailabilityQueryCountTest locks the query count flat: one day and ninety days cost
exactly the same number of queries. Without it the first refactor can put a query
inside the day loop and a 90-day response quietly becomes hundreds of queries —
something only production would reveal.
app:resource:calendar:backfill derives shifts from existing WeeklySchedule sessions,
so the resources created in task 02 are not left with empty calendars. It skips any
resource a user has already configured, which is also what makes it idempotent. The
weekly schedule itself is untouched: this is a copy, not a migration.
Also added --replace to the holiday import. upsert keys on the date, so a row written
with a *wrong* date can never correct itself — re-running just creates the right row
beside the wrong one. That is exactly what happened after fixing the Jalali
conversion bug, and it was caught while capturing real responses for the docs.
Deferred with reasons recorded in the checklist: seasonal shift validity (two
nullable columns can be added later without backfill, so "needed from day one" does
not hold), and a Jalali date picker in the exception form.
1154 tests / 3229 assertions. phpstan at its 14-error baseline, none in touched
files. tsc clean, vitest 622 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
04d3222559 |
feat(resource): admin UI for resources, types, skills and pools, plus real API docs
Four pages on the existing design system: a resources list whose branch/type/skill/
status filters live in the URL and go straight to the server, and three supporting
pages for types, skills and pools. Filtering client-side over a list the server had
already filtered would have been a second source of truth, so the page does neither.
The pool members dialog only offers resources from the pool's own branch and type —
the same rule the server enforces with 422, applied early so the user never reaches
the error. Skill assignment and pool membership are both full replacements, and both
say so in the dialog, because a partial-looking save that silently drops rows is
worse than an explicit one.
Wiring that was missing: deactivating a staff member through
PATCH /api/v1/staff/{uuid}/toggle now closes their resource too. Without it an
inactive operator would still have shown up in availability search. It is an explicit
call rather than a Doctrine lifecycle callback, since callbacks do not fire for
getArrayResult() — which is how every admin list is built — and that asymmetry is
its own bug. The reverse does not hold: closing a resource does not deactivate the
person, who may be purely administrative.
docs/api/resource.md documents all sixteen endpoints with responses captured from
real curl runs against ddev, including the 422 bodies for person-capacity and
non-scalar attributes. staff.md gains a "relationship to resources" section stating
that job_title is not a skill. tenancy.md contrasts these aggregate children —
whose roots do carry a tenant pair — with the branch_working_hours case from task 01,
where the root was global and the classification was wrong.
Also fixed a pre-existing flaky test: NumericFieldNormalizerTest guarded its random
mobile against collision on the never-reset db_test but not its random national code,
so a full-suite run could fail with 422 and close the EntityManager, taking an
unrelated test down with it. Both are now guarded, and the assertion prints the
server's response instead of a bare "422 is not 201".
Verified: phpunit 1119 tests / 3113 assertions green; slot-mode frozen contract green;
phpstan 14 errors before and after, none in touched files; tsc clean; vitest 88 files
/ 617 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d813843fcd |
feat(branch): admin UI for branch working hours and rooms, plus real API docs
Three pages, all on the existing design system: BranchesPage lists the current environment's booking locations with their working-hours and active-room counts, and two subpages edit the week and the rooms. The list page deliberately does not create or rename a branch — clinic and doctor detail pages already do that, and duplicating it would give one physical place two edit surfaces. Route permission reuses `appointment_settings` rather than inventing a new one. Two real bugs fell out of exercising this end to end: `days` was serialising as a JSON *array*, not an object keyed "0".."6" — keys 0..6 are sequential so json_encode collapses them to a list. The client reads days["0"] either way, so nothing looked broken, but the response shape was unstable: one missing day would flip the same field to an object. The controller now casts to stdClass and WorkingHoursTest::testDaysIsAJsonObjectNotAnArray pins it. Found by curling the endpoint for the docs, not by any test. `<input type="time">` caps at 23:59, so it can neither display nor produce the legal end value 1440. An all-day range would have vanished from the form and been corrupted by the first save. Ranges now carry an explicit end-of-day flag, with a round-trip test proving 1440 survives. docs/api/branch.md documents all eight endpoints with responses captured from real curl runs against ddev, including the 422 and 404 bodies. doctor.md records that active/timezone now appear on all nine existing address endpoints (additive), and tenancy.md gains the two lessons this task taught: an aggregate child whose root is itself declared global inherits no environment and needs a real pair, and TenantFilter is not a substitute for an explicit ownership check because hard isolation only applies to a *chosen* context. Verified: phpunit 1067 tests / 2974 assertions green; slot-mode frozen contract green; phpstan 14 errors before and after, none in touched files; tsc clean; vitest 87 files / 612 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eebb363b9f |
feat(branch): branch working hours and rooms on the existing address entity
Task 01 planned a new `branches` table with `doctor_addresses.branch_id` bridging to it. That plan was wrong: the branch already exists and is called `DoctorAddress`. It carries name, address, telephone, coordinates, city/province FKs and an owner (`forDoctor` / `forClinic` + `type`), and the whole system already consumes it with exactly that meaning — `WeeklySchedule.sessions[].location_id` points at `doctor_addresses.id`, `appointment-booking-locations` calls each row a booking location, and nine CRUD endpoints plus four admin pages manage them. A parallel table would mean two sources of truth for one physical place and a branch that `location_id` never references. So no `branches` table and no duplicate branch CRUD. Only the three genuinely missing pieces: - `doctor_addresses.active` / `.timezone`, both NOT NULL with a default so existing rows need no backfill and no current behaviour changes. `active` is stored only — applying it to slot calculation is task 03, since touching `SlotCalculatorService` is off limits in this phase. - `branch_working_hours`, keyed to `doctor_addresses.id`. Minutes from midnight rather than "09:00" strings so range intersection stays arithmetic. PUT replaces all seven days; validation of the whole week runs before any DELETE, so an invalid sixth day cannot wipe the five valid ones and then answer 422. - `rooms`, with `capacity` as concurrency (a three-bed injection room is one resource with capacity 3, not three resources) and a deletion-guard iterator so tasks 02 and 07 can add reasons without editing RoomService. `BranchWorkingHours` first registered as an aggregate child of `DoctorAddress`; TenantSchemaCoverageTest rejected it correctly, because that root is itself declared global. It now carries a real tenant pair instead, derived in the constructor from the address's `type` — a total mapping, and the address is only ever listed in its own context, so nothing is hidden wrongly. RoomController checks ownership explicitly rather than trusting TenantFilter: hard isolation only applies to a *chosen* context, so a doctor who had not selected one could PATCH another clinic's room. Caught by RoomCrudTest::testForeignRoomIsNotFound, which failed with 200 before the fix. 35 tests, 97 assertions. Slot-mode frozen contract still green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
043713275c |
test(booking): freeze the public-site appointment contract; close task 00b
Adds PublicSiteAppointmentContractTest over GET /api/v1/appointments/user — the endpoint the public site's user panel actually calls. Task 00's note claimed this prerequisite was met by extending my/appointments, which is the admin panel's endpoint; appointments/user returns Appointment::toArray(), which the same task extended separately. The outcome was right, the reasoning in the note was not. This test pins it so neither can drift silently: breaking these fields produces no build error in either repo. Documents why appointment-service-slots cannot be grouped into shifts by the client, and records task 00b's checklist including the two items deliberately not done (colour rewrite, reschedule button) with the evidence for each. Task: docs/new_feture/taskes/task-00b-nobat724-service-mode/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9891c2e44a |
docs(booking): document service booking mode and close task 00
docs/api/appointment.md gains the service-reschedule endpoint, the service-mode section under PATCH, exclude_appointment_uuid and clinic_uuid on appointment-service-slots, and the my/appointments additions. All JSON bodies are real output captured from the running endpoints, not hand-written. New docs/architecture/booking-modes.md holds the endpoint/mode matrix, the duration contract with a worked example (35 + 10 buffer means a 45-minute step, so 11:00 is not offered even though it looks free), the reserve-entry rules, and a placeholder for the resource mode task 06 will add. Also fixes a pre-existing flaky test that blocked a green suite: NumericFieldNormalizerTest used a fixed national_code against db_test, which is never reset, so depending on execution order the endpoint rejected it as a duplicate. The test already looped for a unique mobile but not for the national code. Out of this task's scope, fixed and declared so the definition of done is actually green rather than apparently green. phpstan was measured against the pre-task commit rather than asserted: 14 errors in 9 files before, the same 14 in the same 9 files now. Task 00 complete: 1026 tests green across three consecutive runs, 604 frontend tests green, slot-mode contract frozen and verified. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7482eb2ba3 |
feat(booking): add backfill command for service duration columns
Fills service_total_minutes/service_buffer_minutes on future service-mode appointments booked before the columns existed. The value comes from the appointment itself (slot_end - slot_start), not from recomputing the services: an existing appointment may have been booked with a manual duration and recomputing would rewrite the past. Slot-mode, past, reserve and cancelled appointments are skipped. Dry-run by default. Idempotency comes from the query filtering on serviceTotalMinutes IS NULL rather than from a flag, so a second run has nothing to do. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f1ea7bb161 |
docs(booking): record task 00 checklist through feature 8
Marks two task-text assumptions as unfounded with the evidence (DataTable migration, i18n file), defers the URL-state row to a task that owns it, and logs the three pieces of work discovered mid-run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
50759bf663 |
feat(admin): service-aware time picking on the appointment edit page
In service mode the page now mounts the existing ServiceSlotPicker and hides the three free-form time inputs plus the single-service select: a 45-minute service could previously be shortened to 20 and the next patient would sit on top of it. Hidden rather than disabled — a disabled field reads as "you must do something here". Saving splits in two: the service-aware endpoint takes the time and services (the client sends no duration), then the usual PATCH carries deposit, insurance, status and note without slot_start/slot_end/version, since the reschedule already advanced the optimistic-lock version. Booking mode is read from the appointment's own schedule via an explicit clinic_uuid, not from the panel's current environment: a doctor can be slot-based in their office and service-based in a clinic. That required exposing clinic_uuid in Appointment::toArray(), which was missing. appointment-service-slots accepts exclude_appointment_uuid, gated on canManage of that appointment — an ungated parameter would let anyone fabricate availability. ServiceSlotPicker gained two optional props; its existing callers pass neither and are unaffected. Its reset-on-doctor-change effect now skips the first run, which would otherwise wipe the initial selection. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bfe7f36a45 |
feat(booking): add service-aware reschedule endpoint
POST /api/v1/appointment/{uuid}/service-reschedule takes only a start time and
derives the length from the appointment's services. PATCH also validates the
duration, but the client must already know the correct slot_end; not needing that
knowledge is what lets the edit form drop its manual time inputs.
The start must be a member of getServiceStartTimes(), not merely free:
isSlotTaken() reports collisions with other appointments, while the offered list
also applies shift bounds, holidays, date overrides, the booking window and the
buffer. Without it a secretary could park an appointment at 3am.
forManagement comes from canManageContext(), not canManage(): a patient moving
their own appointment must still respect the public booking window.
Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
231735162e |
docs(booking): record task 00 checklist state through feature 5
Also logs three side findings as debt rather than silently passing: an unreproduced flaky failure in the combined suite, a pre-existing PHPUnit notice, and db_test having a migration history separate from dev (later tasks will need the same manual ALTER). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b784124b7e |
feat(booking): let service slot search exclude the appointment being moved
findBusyIntervals() and getServiceStartTimes() gain an optional excludeAppointmentId, mirroring isSlotTaken($doctor, $start, $end, $excludeId) which already had it. Without it an appointment being rescheduled sees itself as busy, so its current time never appears among the candidates and "same hour, different service" is impossible. The parameter is optional with a null default and only affects the service-mode path; no existing call site changes behaviour. SlotModeFrozenTest caught the signature change immediately while both response contracts stayed green, so the signature fixture was updated once with a written rationale, as its own header permits. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6c2e075eea |
feat(booking): persist service duration and allow full service replacement
Appointment gains:
- replaceServiceItems(): full replacement that unconditionally syncs the
legacy single serviceItem column. addServiceItem() only fills it when null,
which would leave stale service names in the four consumers that read
service_item (admin lists, public site, desktop app).
- currentServiceUuids(): input-order uuids, falling back to the single column
for appointments created before multi-service support.
- service_total_minutes / service_buffer_minutes (both nullable, NULL in slot
mode). slot_end - slot_start carries the number but cannot say whether it
was intentional, and a reserve entry has slot_start == slot_end so its
duration had nowhere to live.
Existing columns untouched: slot_start, slot_end, active_slot_key, is_reserve
verified unchanged via SHOW COLUMNS.
Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6afc5c090e |
refactor(booking): extract ServiceBookingCalculator from the controller
"Allowed duration of a service combination" lived inside AppointmentController::serviceSlots(). Three upcoming callers need the same computation (PATCH duration validation, service-aware reschedule, reserve conversion); copying it would mean four variants with four different edge-case behaviours. The extraction is behaviour-preserving: BaseController::error() and ExceptionSubscriber emit an identical envelope, so returning $this->error() was replaced by throwing AppException with the same code/message/field. Tenant ownership now goes through TenantOwnershipChecker::belongsToPair() (the documented single point) instead of an inline section pair comparison. The repo property is named itemRepo on purpose: TenantLookupInventoryTest only counts recognised property names, so any other name would slip past the safety net. The naive duration sum is kept deliberately — switching to solo/additional minutes is task 04 and changes one line here. Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c37c0cee39 |
test(booking): freeze slot-mode contract before service-mode work
Adds SlotModeFrozenTest (#[Group('slot-mode-frozen')]) locking three things
against the multi-resource booking phase:
- GET /api/v1/appointment-slots response shape
- GET /api/v1/appointment-settings/month-availability/{uuid} response shape
- public method signatures of SlotCalculatorService
Fixtures are structural, not raw snapshots: a fixed past date is rejected by
isWithinBookingWindow so an empty snapshot would prove nothing. Instead a
deterministic schedule on a computed near-future date, with epoch/uuid values
normalized to placeholders. What stays locked is the contract itself: keys,
ordering, types and local times.
No production code touched.
Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green, 3 tests)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|