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>
6.0 KiB
Waitlist — لیست انتظار
اندپوینتهای src/Waitlist/*. «اگر وقتی در این بازه آزاد شد، خبرم کن» — توسعهٔ همان
ایدهٔ Appointment.is_reserve موجود، ولی با بازهٔ صریح و وضعیت.
چرا broadcast و نه صف انحصاری
ظرفیت آزادشده به حداکثر ده نفر خبر داده میشود و اولین رزروکننده میبرد.
صف انحصاری («فقط نفر اول ۳۰ دقیقه فرصت دارد») روی کاغذ عادلانهتر است، ولی در عمل یعنی وقتی که کسی جوابش را نمیدهد نیم ساعت قفل بماند و بعد به نفر دوم برسد — و ظرفیتی که دو ساعت مانده به نوبت آزاد شده، نیم ساعت وقتِ تلفکردنی ندارد.
در عوض متن پیامک اجباراً این را میگوید:
«یک وقت برای «X» در تاریخ Y آزاد شد. اولین نفری که رزرو کند آن را میگیرد.»
هر درخواست حداکثر سه بار خبر میگیرد؛ بدون سقف، یک بازهٔ پرلغو به منبع اسپم تبدیل میشود.
GET /api/v1/waitlist
| Query | Type | Description |
|---|---|---|
status |
string | waiting | notified | converted | expired |
{
"success": true,
"data": [
{
"uuid": "…",
"patient_uuid": "…",
"service_uuid": "…",
"service_name": "لیزر",
"branch_id": 12,
"desired_from": 1785600000,
"desired_to": 1785859200,
"preferred_day_parts": ["evening"],
"priority": 0,
"status": "waiting",
"notified_at": null,
"notify_count": 0,
"created_at": 1785486000
}
]
}
POST /api/v1/waitlist
| Field | Type | Required | Description |
|---|---|---|---|
patient_uuid |
string | ✅ | |
service_uuid |
string | ✅ | |
desired_from / desired_to |
int | ✅ | Unix؛ بازه باید در آینده و حداکثر ۹۰ روز باشد |
branch_uuid |
string | — | نبودنش یعنی «هر شعبه» |
preferred_day_parts |
string[] | — | فهرست بسته: morning · afternoon · evening |
priority |
int | — | بزرگتر زودتر خبر میشود |
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_001 |
422 | بازهٔ گذشته · پایانِ قبل از شروع · بازهٔ بیش از ۹۰ روز · بخش روز ناشناخته |
ERR_VALIDATION_002 |
422 | فیلد الزامی غایب |
ERR_NOT_FOUND_001 |
404 | بیمار یا سرویس خارج از محیط جاری |
DELETE /api/v1/waitlist/{uuid}
GET /api/v1/waitlist/matches
| Query | Type | Required |
|---|---|---|
service_uuid |
string | ✅ |
start |
int | ✅ |
branch_uuid |
string | — |
چه کسانی منتظر این سرویس در این لحظهاند؟ مرتب بر اساس اولویت، بعد قدمت. درخواستی که شعبهٔ دیگری خواسته در نتیجه نمیآید؛ درخواست بیشعبه همیشه میآید.
بخش روز — کجا معنا میشود
مرزها در WaitlistEntry::DAY_PARTS است و در تطبیق اعمال میشود، نه فقط ذخیره:
| کلید | برچسب | ساعت محلی |
|---|---|---|
morning |
صبح | [۶, ۱۲) |
afternoon |
بعدازظهر | [۱۲, ۱۷) |
evening |
عصر | [۱۷, ۲۲) |
ساعت به وقت محلی شعبه (DoctorAddress::getTimezone()) حساب میشود؛ بیمار «عصر» را
با ساعت خودش میفهمد نه با UTC. شعبهٔ نامشخص به Asia/Tehran برمیگردد.
فیلتر پیش از بریدن به ده نفر اجرا میشود — وگرنه ده جای اول را کسانی پر میکنند که این ساعت را نمیخواستند و نفر یازدهمِ واقعی خبر نمیشود. نداشتن ترجیح یعنی «هر ساعتی».
چرخهٔ عمر ردیف
| گذار | کِی |
|---|---|
waiting → notified |
اطلاعرسانی هنگام آزاد شدن ظرفیت (سقف ۳ بار) |
* → converted |
همان بیمار همان سرویس را در بازهٔ خواستهشده رزرو کرد |
* → expired |
desired_to گذشت |
converted از رویداد AppointmentBooked میآید، نه از داخل BookingService: تبدیل اثر
جانبیِ رزرو است و اگر داخل تراکنش رزرو مینشست، یک خطای لیست انتظار میتوانست نوبت واقعی
بیمار را برگرداند. تطبیق عمداً تنگ است (همان بیمار + همان سرویس + زمان داخل بازه)؛ تطبیق
شل، ردیفی را میبندد که برای خدمت دیگری بود و بیمار دیگر هرگز خبر نمیشود. idempotent
است، پس تحویل دوبارهٔ پیام بیخطر است.
انقضا هر روز با زمانبند (ExpireWaitlistMessage) یا دستی:
ddev exec php bin/console app:waitlist:expire
ردیف منقضی از قبل هم در تطبیق نمیآمد (desiredTo >= now)، پس این پاکسازیِ نمایش
است: بدون آن صفحهٔ لیست انتظار پر میشود از انتظارهای مرده. حذف نمیکند، وضعیت را عوض
میکند — چه کسی منتظر ماند و به نتیجه نرسید، خودش داده است.
اطلاع خودکار هنگام لغو
POST /api/v1/appointment/{uuid}/cancel بعد از آزادسازی ظرفیت، لیست انتظار را خبر
میکند و تعدادش را در waitlist_notified برمیگرداند. جزئیات لغو:
cancellation.md
تستها
ddev exec php bin/phpunit tests/Waitlist # ۱۶ تست