Files
clinicpro/docs/api/appointment-booking.md
T
hamedandClaude Opus 5 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>
2026-07-31 09:28:55 +03:30

7.2 KiB

Appointment Booking API — رزرو موقت و ثبت نهایی چندمنبعی

Base: /api/v1 · Auth: JWT دنبالهٔ appointment-availability.md.


سه مرحله

جستجو  →  رزرو موقت (hold)  →  ثبت نهایی (confirm)

مرحلهٔ میانی لازم است چون بین دیدن یک زمان و ثبتش، فاصله هست: بیمار فرم پر می‌کند، پرداخت می‌کند، مردد می‌شود. بدون رزرو موقت، همان زمان به چند نفر پیشنهاد می‌شود و آخری خطا می‌گیرد.

چرا تضمین در دیتابیس است، نه در کد

قانون سوم جمع‌بندی مستند: «جلوگیری از رزرو تکراری کار دیتابیس است، نه کار کد.»

هر بررسیِ «آیا آزاد است؟» در PHP یک پنجرهٔ مسابقه بین خواندن و نوشتن دارد؛ دو درخواست هم‌زمان هر دو «آزاد» می‌بینند و هر دو می‌نویسند.

MariaDB قید EXCLUDE بازه‌ای ندارد، پس هر بازهٔ اشغال به سطل‌های ثابت پنج‌دقیقه‌ای شکسته می‌شود و کلید یکتای زیر تداخل را غیرممکن می‌کند:

UNIQUE (resource_id, bucket_at, seat)

کد فقط INSERT می‌زند؛ اگر دیتابیس ردش کرد، همان یعنی «گرفته شده».

seat ظرفیت را بیان می‌کند. اتاق سه‌تخته صندلی‌های ۰ تا ۲ دارد؛ تلاش از صندلی ۰ شروع می‌شود و با هر برخورد یکی جلو می‌رود. چهارمین رزروِ هم‌زمان جایی برای نشستن پیدا نمی‌کند و 409 می‌گیرد. شمردن ظرفیت در PHP دقیقاً همان مسابقه‌ای را می‌ساخت که این طراحی حذفش می‌کند.


POST /api/v1/appointment-hold

{
  "service_uuid": "…",
  "branch_uuid": "…",
  "start": 1785562200,
  "item_uuids": ["…"],
  "patient_gender": "female",
  "assignment": { "room": ["…"], "operator": ["…"], "device": ["…"] }
}

assignment همان چیزی است که جستجوی وقت پیشنهاد داده. هر نیازمندی باید منبع داشته باشد؛ وگرنه 422 — رزروی که نصف منابع لازم را بگیرد، هنگام حضور بیمار کم می‌آورد.

۲۰۱:

{
  "success": true,
  "data": {
    "hold_uuid": "…",
    "starts_at": 1785562200,
    "ends_at": 1785565800,
    "expires_at": 1785563100,
    "confirmed": false,
    "assignment": { "room": [{ "uuid": "…", "name": "اتاق ۲" }] }
  }
}

مهلت ۹۰۰ ثانیه است — همان مهلتی که پرداخت نوبت دارد؛ دو عدد متفاوت یعنی دو حقیقت متفاوت.

بلافاصله پس از رزرو، POST /appointment-availability آن زمان را دیگر برنمی‌گرداند.

۴۰۹ ERR_SLOT_TAKEN: منبع در آن بازه ظرفیت خالی ندارد. ۴۲۲: نبودِ منبع برای یک نقش · assignment خالی. ۴۰۴: سرویس، شعبه یا منبع محیط دیگر.

رزرو نیمه‌کاره نمی‌ماند. اگر منبع دوم جا نداشت، منبع اول هم آزاد می‌شود و خودِ رزرو حذف — وگرنه منبعی قفل می‌ماند که هرگز نوبتی رویش ثبت نمی‌شود.

DELETE /api/v1/appointment-hold/{uuid}

آزادسازی زودهنگام؛ آن زمان بلافاصله دوباره در جستجو ظاهر می‌شود. رزروی که ثبت نهایی شده آزاد نمی‌شود (422).

POST /api/v1/appointment-confirm

{ "hold_uuid": "…", "doctor_uuid": "…", "patient_uuid": "…" }

patient_uuid اختیاری است؛ نبودش یعنی خودِ کاربر (منشی می‌تواند برای دیگری ثبت کند).

۲۰۰: appointment_uuid + بازه + تخصیص.

تبدیل hold → booked هیچ منبعی را دوباره نمی‌گیرد: صندلی‌ها از لحظهٔ رزرو موقت گرفته شده‌اند و اینجا فقط برچسبشان عوض می‌شود. اگر ثبت نهایی دوباره رزرو می‌کرد، همان پنجرهٔ مسابقه‌ای که رزرو موقت حذفش کرده بود برمی‌گشت.

۴۰۹ ERR_HOLD_EXPIRED روی رزروِ منقضی · ۴۰۹ ERR_SLOT_TAKEN روی رزروِ قبلاً ثبت‌شده · ۴۰۴ روی رزرو کاربر دیگر (نه ۴۰۳ — وجودش نباید لو برود).

POST /api/v1/appointment/{uuid}/rebook

جابه‌جایی: اول رزرو جدید، بعد آزادسازی قدیم. ترتیب عمدی است — اگر رزرو جدید شکست بخورد، نوبت قدیمی دست‌نخورده می‌ماند و بیمار بی‌نوبت نمی‌شود.


اشغال: یک ردیف per (بخش × منبع)

نه یکی per نوبت. همین ریزدانگی ظرفیت آزاد می‌کند.

مثال واقعی (و تستِ مرجع): نوبت ۵۵ دقیقه‌ای با بخش‌های بی‌حسی ۵ · انتظار ۳۰ · لیزر ۲۰:

منبع تعداد ردیف اشغال
اتاق ۳ — هر سه بخش
اپراتور ۲ — فقط بی‌حسی و لیزر

اپراتور در بازهٔ انتظار هیچ ردیفی ندارد و برای بیمار دیگری آزاد است.

بازهٔ ثبت‌شده گسترده‌تر از بازهٔ بخش است: زمان آماده‌سازی و تمیزکاری منبع هم درونش می‌آید.

status

مقدار یعنی
hold رزرو موقت، تا پایان مهلت
booked نوبت قطعی
released لغو یا منقضی

لغو، ردیف را حذف فیزیکی نمی‌کند. تاریخچهٔ اینکه چه منبعی کِی گرفته شده بود ورودی گزارش بهره‌وری است؛ حذفش یعنی پاک کردن همان چیزی که قرار است اندازه بگیریم. ولی سطل‌های یکتایی حذف می‌شوند، وگرنه آن زمان برای همیشه قفل می‌ماند.


تور ایمنی دوگانه

نوبت با همان سازندهٔ موجود ساخته می‌شود، پس active_slot_key، رویدادها و مسیر پرداخت دقیقاً مثل قبل کار می‌کنند. اشغال چندمنبعی کنار آن می‌نشیند، نه به‌جایش.


تست‌ها

ddev exec php bin/phpunit tests/Appointment/HoldAndBookTest.php   # ۱۲ تست

دو تست از همه مهم‌ترند: رزرو دوم روی همان منبع و بازه که 409 می‌گیرد، و تستی که مستقیم روی یک اتصال جدا ردیف تکراری می‌نویسد و انتظار نقض کلید یکتا دارد — اگر آن یکی بشکند، یعنی تضمین فقط در کد بوده است.