26425bec31306145cb786a08f2bb8ee42fbb2ee0
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
26425bec31 |
Manage a resource's services from the panel
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> |
||
|
|
a6acf3bfe2 |
Book for a resource, and manage the services a resource offers
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>
|
||
|
|
6d7c54508c |
Let categories contain other categories, and share them with resources
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> |
||
|
|
021d9f82a2 |
Pick candidate resources by whether they actually offer the service
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> |
||
|
|
826b940c00 |
Add the resource↔service link that decides who offers what, and for how much
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
1fdfdf9e48 |
feat(resource): resource calendars, exceptions and national holidays
Section 9 of the design document builds free time by subtracting seven layers. Four existed and all of them hung off the doctor. This adds the missing ones and puts them on the resource: branch hours ∩ resource shifts − national holidays − resource exceptions Booked appointments and holds are deliberately NOT subtracted here — those are tasks 06/07, as is intersecting several resources. The method is called rawAvailability() so nobody mistakes the output for bookable time. Nothing in this change calls SlotCalculatorService; the existing slot path stays frozen. Four types of exception (leave, absence, maintenance, ad-hoc closure) share one table because all four are "an interval subtracted from a resource's calendar"; splitting them would mean four queries per availability lookup instead of one. Holiday overrides work in both directions: a clinic that opens on a public holiday, and a clinic that closes on an ordinary day. Every empty day carries a reason (national_holiday, no_shift, branch_closed, outside_branch_hours, exception, …). Without it an empty response is indistinguishable from a bug and the first person debugging has to read four tables by hand. Three real defects found on the way: JalaliDateService.gregorianToJalali() was wrong — it returned [3006, 7, 3] for 2026-07-30 instead of [1405, 5, 8], roughly 1601 years off. jalaliYear(), jalaliMonth(), jalaliMonthRange() and jalaliYearRange() all inherit that, so the representation reports built on them have been filtering by nonsense ranges. The class's own formatDateTime() was already correct because it used IntlDateFormatter, so both conversions now go through the same mechanism, and JalaliDateServiceTest pins Nowruz and the 6/31→7/1 boundary. There were no tests before, which is why nobody noticed. TimeInterval added a seconds-based midnight to a minutes-based interval, turning an eight-hour shift into eight seconds. The conversion is now an explicitly named minutesToAbsolute() so the unit change cannot happen silently again. HolidayService.upsertNational() persisted but left flushing to the caller. Every HTTP request reboots the kernel, so the caller often held a different EntityManager: persist landed on one, flush on the other, and nothing was written with no error at all. The write is now self-contained. 119 tests across tests/Resource, tests/Branch and tests/Representation. phpstan clean on both touched domains. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
964c09cc00 |
feat(resource): resource types, resources, skills and pools
The document's first golden rule is "the calendar belongs to the resource, not to the doctor". Today the only thing that can be occupied is a doctor, and ClinicStaff is a label on services and appointments with no calendar, capacity or skills. This adds the layer underneath: anything that can be busy — doctor, operator, assistant, device, room, bed, chair. Two corrections to the planned schema: - `address_id` → doctor_addresses, not `branch_id` → a new branches table. The branch already exists and is the address (task 01). - UNIQUE is (doctor_id, address_id), not (doctor_id). A WeeklySchedule is per (doctor, clinic) but every session inside it carries its own location_id, so one doctor already works at several addresses within one environment. Keying on the doctor alone would have made that unrepresentable — and task 03 gives each resource its own calendar, which is exactly per-location. Design points worth keeping: - Resources bridge to Doctor/ClinicStaff/Room rather than absorbing them; those three have live consumers (appointments.doctor_id, service_item_staff, the public site) and subclassing would mean migrating all of them at once. At most one bridge column is non-null, enforced in the entity because MariaDB will not reliably enforce a multi-column CHECK. - Capacity is concurrency: a three-bed injection room is one resource with capacity 3, not three resources, so occupancy in task 06 stays a COUNT against a limit instead of a merge of three calendars. A person resource is refused capacity > 1. - Skills are a table, not rules. With 50 operators and 200 services, expressing "who may operate what" as policy would mean 10,000 rules. - findEligible() uses HAVING COUNT(DISTINCT …) because "skills A and B" means both; a plain IN would have matched a resource holding only one. - setup/cleanup minutes occupy the resource without being part of the patient's appointment, and are per-resource — distinct from the existing per-doctor WeeklySchedule.meta.buffer_minutes, which stays untouched. Two real bugs found by running the backfill against real data rather than fixtures: ResourceLinker::systemType() persisted a type without flushing, so the next lookup missed it and created a second — the run died on "Duplicate entry 'doctor-1-staff' for key uniq_rt_tenant_code". It now keeps an identity map for the unit of work. The command looped over every WeeklySchedule once per environment, which is quadratic and never finished on real data. Doctors are now a single pass keyed by the schedule's own environment. It also flushes per environment and accepts --pair=clinic:12, so one bad row cannot close the EntityManager and abort a fleet-wide run, and operators can re-run for a single clinic. Staff are the one case that cannot be derived: nothing records which branch they work at. Rather than guessing the first one and seating them in the wrong building, multi-branch environments are skipped and reported. 88 tests, 230 assertions across tests/Resource and tests/Branch. phpstan clean on src/Resource. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |