Files
clinicpro/docs/api/waitlist.md
T
hamedandClaude Opus 5 4bba322b8e feat(waitlist): make the day-part preference, the conversion and the expiry real
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>
2026-08-01 13:33:26 +03:30

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   # ۱۶ تست