From 4216e67d68d1a99313c5a186ed51c1dfb2259c93 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 15 Jun 2026 16:48:50 +0330 Subject: [PATCH] fix(appointment): mark past-time slots as unavailable is_available only checked whether a slot was booked, so on today's date slots whose start time had already passed (e.g. 10:00 when it is 12:00) still showed as bookable until the POST /appointment 422. Treat a slot with start < now as unavailable in getAllSlotsWithAvailability, and drop past slots in filterBookedSlots so getAvailableSlots agrees. Future days are unaffected. Co-Authored-By: Claude Opus 4.8 --- .claude/prompt/mark-past-slots-unavailable.md | 116 ++++++++++++++++++ docs/api/appointment.md | 2 +- .../Service/SlotCalculatorService.php | 8 +- 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 .claude/prompt/mark-past-slots-unavailable.md diff --git a/.claude/prompt/mark-past-slots-unavailable.md b/.claude/prompt/mark-past-slots-unavailable.md new file mode 100644 index 00000000..4fe46a14 --- /dev/null +++ b/.claude/prompt/mark-past-slots-unavailable.md @@ -0,0 +1,116 @@ +# غیرقابل‌رزرو کردن اسلات‌های گذشته در روز جاری + +## پروژه + +`clinicpro` (Backend). **این پرامپت اول اجرا شود.** + +> **Cross-repo:** خروجی `is_available` این endpoint توسط سایت عمومی مصرف می‌شود. پرامپت همتا در سمت فرانت: +> `nobat724_front/.claude/prompt/verify-past-slots-disabled.md` + +## زمینه + +`GET /api/v1/appointment-slots?doctor_uuid=&date=` اسلات‌های یک روز را گروه‌بندی‌شده در `sessions[].slots[]` برمی‌گرداند و هر اسلات یک فلگ `is_available` دارد. سایت عمومی (`List.js`) دقیقاً بر اساس همین `is_available` اسلات را فعال/خاکستری می‌کند. + +مشکل: `is_available` فقط چک می‌کند که اسلات **رزرو شده** است یا نه (`isSlotTaken`). زمان گذشته را در نظر نمی‌گیرد. پس اگر امروز ساعت ۱۲ باشد، اسلات ساعت ۱۰ همان روز هنوز `is_available: true` برمی‌گردد و در UI قابل کلیک است. + +> نکته: endpoint رزرو (`POST /api/v1/appointment`) **از قبل** اسلات گذشته را رد می‌کند (`if ($slotStart < time())` → ۴۲۲ «زمان این اسلات گذشته است»). پس داده‌ی نامعتبر ثبت نمی‌شود؛ ولی کاربر اسلات گذشته را کلیک‌پذیر می‌بیند و فقط موقع ثبت خطا می‌گیرد. هدف این پرامپت این است که اسلات گذشته از همان ابتدا `is_available: false` باشد. + +## مشکل / هدف + +در محاسبه‌ی `is_available`، اسلاتی که زمان شروعش (`start`، Unix timestamp) قبل از «اکنون» است باید `is_available: false` شود — دقیقاً مثل اسلات رزرو‌شده. این فقط برای روز جاری اثر دارد؛ روزهای آینده اسلات گذشته ندارند و روزهای گذشته از قبل توسط booking window حذف شده‌اند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Service/SlotCalculatorService.php` | محاسبه‌ی `is_available` در `getAllSlotsWithAvailability` و فیلتر در `getAvailableSlots` | +| `src/Appointment/Controller/AppointmentController.php` | `book()` — از قبل اسلات گذشته را رد می‌کند (تغییر نمی‌کند، فقط مرجع) | +| `docs/api/appointment.md` | توضیح `is_available` و رفتار اسلات گذشته | + +## وضعیت فعلی (کد واقعی) + +### `SlotCalculatorService::getAllSlotsWithAvailability()` — is_available فقط رزرو را چک می‌کند + +```php +public function getAllSlotsWithAvailability(Doctor $doctor, string $date): array +{ + $sessions = $this->buildAllSessions($doctor, $date); + return array_map(fn(array $session) => [ + 'start_time' => $session['start_time'], + 'end_time' => $session['end_time'], + 'slots' => array_map(fn(array $slot) => array_merge($slot, [ + 'is_available' => !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']), + ]), $session['slots']), + ], $sessions); +} +``` + +### `getAvailableSlots()` — برای چک کانفلیکت رزرو (اسلات‌های flat قابل‌رزرو) + +```php +public function getAvailableSlots(Doctor $doctor, string $date): array +{ + $sessions = $this->buildAllSessions($doctor, $date); + if (empty($sessions)) return []; + $flat = array_merge(...array_map(fn($s) => $s['slots'], $sessions)); + return $this->filterBookedSlots($doctor, $flat); +} + +private function filterBookedSlots(Doctor $doctor, array $slots): array +{ + return array_values(array_filter($slots, fn(array $slot): bool => + !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']) + )); +} +``` + +## وظایف + +### ۱. اسلات گذشته را در `is_available` غیرفعال کن + +در `getAllSlotsWithAvailability`، شرط `is_available` را طوری تغییر بده که علاوه بر «رزرو نشده»، «در آینده» هم باشد: + +```php +'slots' => array_map(function (array $slot) use ($doctor): array { + $isPast = $slot['start'] < time(); + $isTaken = $this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']); + return array_merge($slot, [ + 'is_available' => !$isPast && !$isTaken, + ]); +}, $session['slots']), +``` + +> از `time()` استفاده کن (تایم‌زون سرور همان است که `buildSessionSlots` با آن `start`/`end` را می‌سازد — هر دو از `strtotime($date.' 00:00:00')` می‌آیند، پس مقایسه‌ی Unix-با-Unix درست است). تبدیل تایم‌زون اضافه نکن. + +### ۲. اسلات گذشته را از `getAvailableSlots` هم حذف کن (سازگاری) + +`filterBookedSlots` (یا یک فیلتر هم‌تراز) باید اسلات گذشته را هم بیندازد، تا منطق conflict-check و هر مصرف‌کننده‌ی این متد با واقعیت هم‌خوان بماند: + +```php +private function filterBookedSlots(Doctor $doctor, array $slots): array +{ + $now = time(); + return array_values(array_filter($slots, fn(array $slot): bool => + $slot['start'] >= $now && + !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']) + )); +} +``` + +> اگر اسم متد دیگر گویا نیست می‌توانی به چیزی مثل `filterUnbookableSlots` تغییر دهی، ولی الزامی نیست. + +### ۳. مستندسازی + +در `docs/api/appointment.md` بخش `appointment-slots`، توضیح `is_available` را به‌روز کن: «`is_available: false` یعنی اسلات یا رزرو شده یا زمانش گذشته است.» + +## نکات مهم + +- **فقط روز جاری متأثر می‌شود.** برای روزهای آینده همه‌ی `start`ها از `time()` بزرگ‌ترند، پس رفتار تغییر نمی‌کند. روزهای گذشته قبلاً با booking window حذف شده‌اند. +- **تایم‌زون:** `start`/`end` اسلات‌ها Unix timestamp ساخته‌شده از `strtotime($date . ' 00:00:00')` در تایم‌زون سرورند؛ `time()` هم Unix همان لحظه است — مقایسه‌ی مستقیم درست است. تبدیل دستی نکن. +- **اسلات در حال جریان:** تصمیم بگیر مبنا `start` باشد (اسلاتی که شروعش گذشته → غیرفعال). همین کافی است و ساده‌ترین رفتار قابل‌انتظار است؛ معیار پیچیده‌تر (مثل «تا n دقیقه قبل از شروع») اضافه نکن مگر خواسته شود. +- `book()` در `AppointmentController` از قبل `$slotStart < time()` را رد می‌کند — این لایه‌ی دفاعی را **دست‌نخورده** نگه‌دار (نباید حذف شود). +- همه‌ی پاسخ‌ها از `BaseController`؛ تغییری در قرارداد JSON `appointment-slots` نیست جز معنای `is_available`. +- تست: + - `ddev exec php -l src/Appointment/Service/SlotCalculatorService.php` + - یک `curl` واقعی روی `appointment-slots` برای پزشک `4a0594b1-008b-478a-a593-259b95d8c2dd` با `date=<امروز میلادی>` بزن و تأیید کن اسلات‌هایی که `start_time` آن‌ها قبل از الان است `is_available: false` دارند و اسلات‌های بعد از الان `true`. + - بعد از تغییر، فایل `docs/api/appointment.md` را به‌روز کن. diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 21282f2c..04731f48 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -56,7 +56,7 @@ Get all appointment slots (available and booked) for a doctor on a specific date } ``` -> Returns **all** slots grouped by work shift. `is_available: false` means the slot has an active (pending/confirmed) appointment. Session boundaries match the doctor's `WeeklySchedule` or date override config. +> Returns **all** slots grouped by work shift. `is_available: false` means the slot is either already booked (active pending/confirmed appointment) **or** its start time has already passed (for today's date). Session boundaries match the doctor's `WeeklySchedule` or date override config. ### Errors | Code | HTTP | Description | diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php index 54dfd030..36d52122 100644 --- a/src/Appointment/Service/SlotCalculatorService.php +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -41,11 +41,13 @@ class SlotCalculatorService public function getAllSlotsWithAvailability(Doctor $doctor, string $date): array { $sessions = $this->buildAllSessions($doctor, $date); + $now = time(); return array_map(fn(array $session) => [ 'start_time' => $session['start_time'], 'end_time' => $session['end_time'], 'slots' => array_map(fn(array $slot) => array_merge($slot, [ - 'is_available' => !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']), + 'is_available' => $slot['start'] >= $now + && !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']), ]), $session['slots']), ], $sessions); } @@ -204,8 +206,10 @@ class SlotCalculatorService private function filterBookedSlots(Doctor $doctor, array $slots): array { + $now = time(); return array_values(array_filter($slots, fn(array $slot): bool => - !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']) + $slot['start'] >= $now + && !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']) )); }