"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>
8.5 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 میگیرد، و تستی که
مستقیم روی یک اتصال جدا ردیف تکراری مینویسد و انتظار نقض کلید یکتا دارد — اگر آن
یکی بشکند، یعنی تضمین فقط در کد بوده است.
قوانین وابسته به بیمار
POST /api/v1/appointment-hold پیش از گرفتن صندلی دو دسته را اجرا میکند:
| دسته | خطا | معنی |
|---|---|---|
eligibility |
ERR_VALIDATION_001 / 422 |
قانونی این بیمار را برای این خدمت رد کرده |
eligibility (require_flag) |
ERR_VALIDATION_002 / 422 |
پرچمی مثل has_parental_consent در بدنه نیامده |
spacing |
ERR_VALIDATION_001 / 422 |
فاصله تا نوبت قبلیِ همان دستهٔ کاتالوگ کمتر از min_days_between است |
جای اجرا عمداً لحظهٔ رزرو موقت است نه ثبت نهایی: شنیدن «واجد شرایط نیستید» بعد از ده دقیقه نگهداشتن صندلی، هم وقت بیمار را تلف میکند هم صندلی را.
جزئیات: policy.md
اعتبار پکیج
confirm یک جلسه از پکیج معتبر بیمار کسر میکند (ردیف consume) و لغو نوبت آن را
برمیگرداند (ردیف refund) — ردیف مصرف حذف نمیشود. کلید یکتای دفتر تضمین میکند
اجرای دوبارهٔ confirm جلسهٔ دوم نخورد.
جزئیات: package.md