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>
This commit is contained in:
+44
-4
@@ -57,15 +57,15 @@
|
||||
|---|---|---|---|
|
||||
| `patient_uuid` | string | ✅ | |
|
||||
| `service_uuid` | string | ✅ | |
|
||||
| `desired_from` / `desired_to` | int | ✅ | Unix؛ بازه باید در آینده باشد |
|
||||
| `desired_from` / `desired_to` | int | ✅ | Unix؛ بازه باید در آینده و حداکثر **۹۰ روز** باشد |
|
||||
| `branch_uuid` | string | — | نبودنش یعنی «هر شعبه» |
|
||||
| `preferred_day_parts` | string[] | — | `["morning","evening"]` |
|
||||
| `preferred_day_parts` | string[] | — | فهرست **بسته**: `morning` · `afternoon` · `evening` |
|
||||
| `priority` | int | — | بزرگتر زودتر خبر میشود |
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|---|---|---|
|
||||
| `ERR_VALIDATION_001` | 422 | بازهٔ گذشته یا پایانِ قبل از شروع |
|
||||
| `ERR_VALIDATION_001` | 422 | بازهٔ گذشته · پایانِ قبل از شروع · بازهٔ بیش از ۹۰ روز · بخش روز ناشناخته |
|
||||
| `ERR_VALIDATION_002` | 422 | فیلد الزامی غایب |
|
||||
| `ERR_NOT_FOUND_001` | 404 | بیمار یا سرویس خارج از محیط جاری |
|
||||
|
||||
@@ -84,6 +84,46 @@
|
||||
|
||||
---
|
||||
|
||||
## بخش روز — کجا معنا میشود
|
||||
|
||||
مرزها در `WaitlistEntry::DAY_PARTS` است و **در تطبیق اعمال میشود**، نه فقط ذخیره:
|
||||
|
||||
| کلید | برچسب | ساعت محلی |
|
||||
|---|---|---|
|
||||
| `morning` | صبح | `[۶, ۱۲)` |
|
||||
| `afternoon` | بعدازظهر | `[۱۲, ۱۷)` |
|
||||
| `evening` | عصر | `[۱۷, ۲۲)` |
|
||||
|
||||
ساعت به وقت **محلی شعبه** (`DoctorAddress::getTimezone()`) حساب میشود؛ بیمار «عصر» را
|
||||
با ساعت خودش میفهمد نه با UTC. شعبهٔ نامشخص به `Asia/Tehran` برمیگردد.
|
||||
|
||||
فیلتر **پیش از** بریدن به ده نفر اجرا میشود — وگرنه ده جای اول را کسانی پر میکنند که
|
||||
این ساعت را نمیخواستند و نفر یازدهمِ واقعی خبر نمیشود. نداشتن ترجیح یعنی «هر ساعتی».
|
||||
|
||||
## چرخهٔ عمر ردیف
|
||||
|
||||
| گذار | کِی |
|
||||
|---|---|
|
||||
| `waiting → notified` | اطلاعرسانی هنگام آزاد شدن ظرفیت (سقف ۳ بار) |
|
||||
| `* → converted` | همان بیمار **همان سرویس** را در بازهٔ خواستهشده رزرو کرد |
|
||||
| `* → expired` | `desired_to` گذشت |
|
||||
|
||||
`converted` از رویداد `AppointmentBooked` میآید، نه از داخل `BookingService`: تبدیل اثر
|
||||
جانبیِ رزرو است و اگر داخل تراکنش رزرو مینشست، یک خطای لیست انتظار میتوانست نوبت واقعی
|
||||
بیمار را برگرداند. تطبیق عمداً تنگ است (همان بیمار + همان سرویس + زمان داخل بازه)؛ تطبیق
|
||||
شل، ردیفی را میبندد که برای خدمت دیگری بود و بیمار دیگر هرگز خبر نمیشود. **idempotent**
|
||||
است، پس تحویل دوبارهٔ پیام بیخطر است.
|
||||
|
||||
انقضا هر روز با زمانبند (`ExpireWaitlistMessage`) یا دستی:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console app:waitlist:expire
|
||||
```
|
||||
|
||||
ردیف منقضی از قبل هم در تطبیق نمیآمد (`desiredTo >= now`)، پس این پاکسازیِ **نمایش**
|
||||
است: بدون آن صفحهٔ لیست انتظار پر میشود از انتظارهای مرده. حذف نمیکند، وضعیت را عوض
|
||||
میکند — چه کسی منتظر ماند و به نتیجه نرسید، خودش داده است.
|
||||
|
||||
## اطلاع خودکار هنگام لغو
|
||||
|
||||
`POST /api/v1/appointment/{uuid}/cancel` بعد از آزادسازی ظرفیت، لیست انتظار را خبر
|
||||
@@ -93,5 +133,5 @@
|
||||
## تستها
|
||||
|
||||
```bash
|
||||
ddev exec php bin/phpunit tests/Waitlist # ۹ تست
|
||||
ddev exec php bin/phpunit tests/Waitlist # ۱۶ تست
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user