docs/architecture/resource-first-model.md describes the shape: the three
entities, why an option is a ServiceItem rather than a fourth table, the
four-level resolution chain, the two conditions on the eligibility filter and
what each of them prevented, and why containment is a graph beside the display
tree rather than the tree itself.
docs/api/resource.md gains both offering endpoints with the response captured
from a real call, including a row where the price comes from the branch and one
where it comes from the resource — the two cases the *_source fields exist for.
docs/api/appointment.md documents resource_uuid, the doctor inference, and the
nullable resource/service_option in the response.
The checklists for tasks 9 to 14 keep their rows but open with a banner saying
the task was removed, when, by whose decision, and which commit to revert. They
are history now; deleting them would erase the record of work that shipped and
was then withdrawn.
Verified end to end: 1304 tests, slot-mode-frozen green, phpstan at 14, tsc
clean, 648 panel tests, and app:seed-scenarios --reset builds all three
environments.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
"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>
- Created checklist for task 11: Package and Credit Ledger
- Created checklist for task 12: Treatment Course
- Created checklist for task 13: Cancellation Policy, No-Show, and Waitlist
- Created checklist for task 14: Domain Events and Utilization Reports