diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md index ff00d92d..935f5075 100644 --- a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md +++ b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۰ (تکمیل نوبت‌دهی سرویسی در clinicpro) -**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۳ از ۱۰ تمام شد (خط سرخ · ServiceBookingCalculator · فیلدهای سرویسی نوبت) +**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۴ از ۱۰ تمام شد **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · @@ -16,11 +16,11 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | ۰.۱ | `SlotModeFrozenTest` + سه fixture ساخته شد **پیش از** هر تغییر کد | ✅ | `tests/Appointment/SlotModeFrozenTest.php` + `tests/Appointment/fixtures/` — هیچ کد تولیدی‌ای هنوز لمس نشده | | ۰.۲ | ~~fixture ها با تاریخ ثابت‌اند، نه `time()`~~ → fixture **ساختاری** است | ✅ | **انحراف عمدی از متن تسک.** تاریخ ثابتِ گذشته را `isWithinBookingWindow` رد می‌کند و snapshot خالی چیزی را تضمین نمی‌کند. به‌جایش: برنامهٔ قطعی (هر ۷ روز یک شیفت ۰۹:۰۰–۱۱:۰۰/۳۰ دقیقه) روی `+3 days`، و epoch/uuid با placeholder نرمال می‌شوند. آنچه قفل می‌شود: کلیدها، ترتیب، نوع‌ها، ساعت‌های محلی | | ۰.۳ | کامنت «read-only، هیچ تسکی به‌روزش نمی‌کند» بالای هر سه fixture | ✅ | کلید `_readme` در دو JSON (در loader حذف می‌شود) + docblock در فایل PHP | -| ۰.۴ | هیچ متد موجود `SlotCalculatorService` ویرایش نشد | ⏳ | قابلیت ۴ — فقط پارامتر اختیاری `excludeAppointmentId` روی `getServiceStartTimes`؛ fixture امضا **یک بار** به‌روز می‌شود (header خودش این را مجاز کرده) | +| ۰.۴ | هیچ متد موجود `SlotCalculatorService` ویرایش نشد | ✅ | فقط `getServiceStartTimes` یک پارامتر **اختیاری** با پیش‌فرض `null` گرفت. **اثبات کارکرد تور ایمنی:** تست منجمد همان لحظه قرمز شد و دقیقاً همان پارامتر را نشان داد، در حالی که دو قرارداد پاسخ سبز ماندند. fixture امضا یک بار با تاریخچهٔ مکتوب به‌روز شد (header خودش مجاز کرده) | | ۰.۵ | `GET /appointment-slots` بیت‌به‌بیت دست‌نخورده | ⏳ | در پایان تسک تأیید می‌شود | | ۰.۶ | `GET /month-availability/{doctorUuid}` دست‌نخورده | ⏳ | در پایان تسک تأیید می‌شود | | ۰.۷ | `active_slot_key` و `refreshActiveSlotKey()` دست‌نخورده | ✅ | `setIsReserve()` **وجود ندارد**؛ toggle رزرو از قبل با `rescheduleTo($start,$end,$isReserve)` انجام می‌شود که خودش `refreshActiveSlotKey()` را صدا می‌زند ([Appointment.php:316](../../../src/Appointment/Entity/Appointment.php)). یادداشت قبلی چک‌لیست غلط بود | -| ۰.۸ | `isSlotTaken` امضا و معنا دست‌نخورده | ⏳ | در پایان تسک تأیید می‌شود | +| ۰.۸ | `isSlotTaken` امضا و معنا دست‌نخورده | ✅ | لمس نشد؛ فقط الگویش تکرار شد | | ۰.۹ | `--group=slot-mode-frozen` سبز | ✅ | `OK (3 tests, 8 assertions)` — نیازمند `#[Group]` attribute بود، نه `@group` (PHPUnit 12 annotation را حذف کرده) | | ۰.۱۰ | baseline: کل `tests/Appointment` پیش از تغییرات سبز | ✅ | `OK (166 tests, 382 assertions)` | @@ -36,7 +36,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | ۱.۶ | `PATCH` مقدار `service_item_uuids[]` می‌پذیرد | ⏳ | | | ۱.۷ | `ReserveConversionService` + `POST /appointment/{uuid}/convert-reserve` | ⏳ | | | ۱.۸ | نوبت رزرو در حالت سرویسی سرویس‌ها را ذخیره می‌کند | ⏳ | | -| ۱.۹ | `excludeAppointmentId` روی `getServiceStartTimes` و `findBusyIntervals` | ⏳ | همان الگوی `isSlotTaken` | +| ۱.۹ | `excludeAppointmentId` روی `getServiceStartTimes` و `findBusyIntervals` | ✅ | همان نام/جای/پیش‌فرضِ `isSlotTaken($doctor,$start,$end,?int $excludeId=null)`. در repository به `occupiedIntervals()` هم پاس داده می‌شود، پس `findBlockingIntervals` بی‌تغییر ماند | | ۱.۱۰ | `Appointment::replaceServiceItems()` + `currentServiceUuids()` | ✅ | کنار `addServiceItem()` موجود؛ `currentServiceUuids()` ترتیب ورودی را حفظ می‌کند و برای نوبت قدیمیِ فقط-تکی هم uuid می‌دهد | | ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را هم‌گام می‌کند | ✅ | برخلاف `addServiceItem()` که فقط وقتی `null` است ست می‌کند، این یکی **بی‌قید** بازنویسی می‌کند (و با فهرست خالی `null` می‌شود). چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند | | ۱.۱۲ | ~~`setIsReserve()` صدا زدن `refreshActiveSlotKey()`~~ | ✅ | **موردی برای انجام نبود:** `setIsReserve()` وجود ندارد؛ toggle رزرو از قبل با `rescheduleTo($start,$end,$isReserve)` است که خودش `refreshActiveSlotKey()` را صدا می‌زند | @@ -90,6 +90,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | | | ۴.۶ | `ServiceModeSectionDurationTest` موجود سبز ماند | ⏳ | | | ۴.۷ | `BookingTenantTest` موجود سبز ماند | ✅ | داخل `tests/Appointment` — کل ۳۰۹ تست `tests/Appointment` + `tests/Shared` سبز | +| ۴.۱۰ | `ServiceSlotExcludeSelfTest` — رفتار exclude | ✅ | ۶ تست / ۱۱ assertion. شامل: بازهٔ خودِ نوبت با exclude برمی‌گردد · مدت بلندتر روی همان ساعت · نوبتِ دیگری همچنان اشغال می‌ماند · `null` صریح و ضمنی خروجی یکسان · فیلتر repository فقط همان ردیف · exclude کردن نوبت رزرو بی‌اثر | | ۴.۹ | `AppointmentServiceFieldsTest` — متدها و ستون‌های جدید | ✅ | ۹ تست / ۲۴ assertion. شامل: هم‌گامی ستون تکی · حفظ ترتیب · فهرست خالی → `null` · حالت اسلاتی هر دو ستون `null` · مدتِ `null` بافر را هم `null` می‌کند · تکراری‌ها dedup · نوبت قدیمیِ فقط-تکی · بقای مقادیر پس از flush/clear | | ۴.۸ | `AppointmentEditPage.test.tsx` — دو حالت | ⏳ | | diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php index 6023dd8d..e65d46fa 100644 --- a/src/Appointment/Repository/AppointmentRepository.php +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -101,11 +101,16 @@ class AppointmentRepository extends ServiceEntityRepository * در حالت نوبت‌دهی سرویسی. همان معیارِ isSlotTaken (blocking یا pendingِ زنده)، * ولی نوبت‌های «آزاد» (is_reserve) هیچ بازه‌ای اشغال نمی‌کنند. * + * `$excludeId` برای جابه‌جایی خودِ یک نوبت است: بدون آن، نوبتِ در حال جابه‌جایی خودش + * را اشغال می‌بیند و زمان فعلی‌اش هرگز در فهرست زمان‌های ممکن نمی‌آید — پس کاربر + * نمی‌تواند «همان ساعت، سرویس متفاوت» را ثبت کند. همان الگوی + * {@see isSlotTaken()} که این پارامتر را از قبل دارد. + * * @return array مرتب‌شده بر اساس start */ - public function findBusyIntervals(Doctor $doctor, int $from, int $to): array + public function findBusyIntervals(Doctor $doctor, int $from, int $to, ?int $excludeId = null): array { - return $this->occupiedIntervals($doctor, $from, $to, true); + return $this->occupiedIntervals($doctor, $from, $to, true, $excludeId); } /** @@ -120,8 +125,13 @@ class AppointmentRepository extends ServiceEntityRepository } /** @return array */ - private function occupiedIntervals(Doctor $doctor, int $from, int $to, bool $skipReserve): array - { + private function occupiedIntervals( + Doctor $doctor, + int $from, + int $to, + bool $skipReserve, + ?int $excludeId = null, + ): array { $qb = $this->createQueryBuilder('a') ->select('a.slotStart AS start, a.slotEnd AS end') ->where('a.doctor = :doctor') @@ -142,6 +152,10 @@ class AppointmentRepository extends ServiceEntityRepository $qb->andWhere('a.isReserve = false'); } + if ($excludeId !== null) { + $qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId); + } + $rows = $qb->getQuery()->getScalarResult(); return array_map(fn($r) => ['start' => (int) $r['start'], 'end' => (int) $r['end']], $rows); diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php index 0fef932e..00498b2c 100644 --- a/src/Appointment/Service/SlotCalculatorService.php +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -100,9 +100,13 @@ class SlotCalculatorService * زمان پایانِ ذخیره‌شدهٔ نوبت = start + duration (بدون بافر)؛ بافر فقط فاصلهٔ * بین دو نوبت است، پس candidate بعدی از start + duration + buffer شروع می‌شود. * + * `$excludeAppointmentId` برای جابه‌جایی خودِ یک نوبت است: بدون آن، نوبتِ در حال + * جابه‌جایی خودش را اشغال می‌بیند و زمان فعلی‌اش در فهرست نمی‌آید. همان الگوی + * {@see \App\Appointment\Repository\AppointmentRepository::isSlotTaken()}. + * * @return array */ - public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null, bool $forManagement = false): array + public function getServiceStartTimes(Doctor $doctor, string $date, int $durationMinutes, ?Clinic $clinic = null, bool $forManagement = false, ?int $excludeAppointmentId = null): array { if ($durationMinutes <= 0) return []; @@ -114,7 +118,7 @@ class SlotCalculatorService if (empty($sessions)) return []; $dayStart = (int) strtotime($date . ' 00:00:00'); - $busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400); + $busy = $this->appointmentRepo->findBusyIntervals($doctor, $dayStart, $dayStart + 86400, $excludeAppointmentId); $now = time(); $result = []; diff --git a/tests/Appointment/ServiceSlotExcludeSelfTest.php b/tests/Appointment/ServiceSlotExcludeSelfTest.php new file mode 100644 index 00000000..f27b7557 --- /dev/null +++ b/tests/Appointment/ServiceSlotExcludeSelfTest.php @@ -0,0 +1,151 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر جابه‌جایی'); + $this->em->persist($doctor); + + $date = date('Y-m-d', strtotime('+2 days')); + $dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7); + + $schedule = $this->newWeeklySchedule($doctor, [ + $dayKey => ['sessions' => [[ + 'active' => true, 'start_time' => '15:00', 'end_time' => '17:00', + 'duration_per_patient' => 20, 'location_id' => 1, + ]]], + ]); + $schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => 0]); + $this->em->persist($schedule); + $this->em->flush(); + + return [$doctor, $date, (int) strtotime($date . ' 15:00')]; + } + + private function confirmedAppointment(Doctor $doctor, int $start, int $minutes): Appointment + { + $patient = $this->createUser(['ROLE_USER']); + $appt = $this->newAppointment($doctor, $patient, $start, $start + $minutes * 60); + $appt->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appt); + $this->em->flush(); + + return $appt; + } + + private function calc(): SlotCalculatorService + { + return static::getContainer()->get(SlotCalculatorService::class); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testOwnIntervalReappearsWhenExcluded(): void + { + [$doctor, $date, $start] = $this->serviceDoctor(); + $appt = $this->confirmedAppointment($doctor, $start, 30); + + $without = array_column($this->calc()->getServiceStartTimes($doctor, $date, 30), 'start_time'); + self::assertNotContains('15:00', $without, 'بدون exclude، نوبت خودش را اشغال می‌بیند'); + + $with = array_column( + $this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, $appt->getId()), + 'start_time', + ); + self::assertContains('15:00', $with, 'با exclude، زمان فعلیِ خود نوبت در فهرست می‌آید'); + } + + public function testExcludingAllowsALongerDurationAtTheSameStart(): void + { + [$doctor, $date, $start] = $this->serviceDoctor(); + $appt = $this->confirmedAppointment($doctor, $start, 30); + + // همان ساعت ۱۵:۰۰ ولی سرویس بلندتر (۶۰ دقیقه) — سناریوی «سرویس را عوض کردم». + $times = array_column( + $this->calc()->getServiceStartTimes($doctor, $date, 60, null, false, $appt->getId()), + 'start_time', + ); + + self::assertContains('15:00', $times); + } + + // ── ❌ خطا / مسیر نادرست ───────────────────────────────────────────────── + + public function testOtherAppointmentsStayBlockingWhenOneIsExcluded(): void + { + [$doctor, $date, $start] = $this->serviceDoctor(); + $mine = $this->confirmedAppointment($doctor, $start, 30); + $others = $this->confirmedAppointment($doctor, $start + 30 * 60, 30); // ۱۵:۳۰ + + $times = array_column( + $this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, $mine->getId()), + 'start_time', + ); + + self::assertContains('15:00', $times, 'نوبت خودم آزاد شد'); + self::assertNotContains('15:30', $times, 'نوبت دیگری همچنان اشغال است'); + self::assertNotNull($others->getId()); + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + public function testNullExcludeIdBehavesExactlyLikeBefore(): void + { + [$doctor, $date, $start] = $this->serviceDoctor(); + $this->confirmedAppointment($doctor, $start, 30); + + $implicit = $this->calc()->getServiceStartTimes($doctor, $date, 30); + $explicit = $this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, null); + + self::assertSame($implicit, $explicit, 'پیش‌فرض null نباید رفتار موجود را عوض کند'); + } + + public function testRepositoryExcludeIdFiltersOnlyThatRow(): void + { + [$doctor, , $start] = $this->serviceDoctor(); + $appt = $this->confirmedAppointment($doctor, $start, 30); + + $repo = static::getContainer()->get(AppointmentRepository::class); + $from = $start - 3600; + $to = $start + 7200; + + self::assertNotEmpty($repo->findBusyIntervals($doctor, $from, $to)); + self::assertSame([], $repo->findBusyIntervals($doctor, $from, $to, $appt->getId())); + } + + public function testExcludingAReserveEntryChangesNothing(): void + { + [$doctor, $date, $start] = $this->serviceDoctor(); + + // نوبت رزرو هیچ بازه‌ای اشغال نمی‌کند، پس exclude کردنش هم بی‌اثر است. + $patient = $this->createUser(['ROLE_USER']); + $dayMid = (int) strtotime($date . ' 00:00:00'); + $reserve = $this->newAppointment($doctor, $patient, $dayMid, $dayMid); + $reserve->rescheduleTo($dayMid, $dayMid, true); + $this->em->persist($reserve); + $this->em->flush(); + + $before = $this->calc()->getServiceStartTimes($doctor, $date, 30); + $after = $this->calc()->getServiceStartTimes($doctor, $date, 30, null, false, $reserve->getId()); + + self::assertSame($before, $after); + self::assertSame($start, (int) strtotime($date . ' 15:00')); + } +} diff --git a/tests/Appointment/fixtures/slot-calculator-signatures.php b/tests/Appointment/fixtures/slot-calculator-signatures.php index 35076f1c..3658e7bb 100644 --- a/tests/Appointment/fixtures/slot-calculator-signatures.php +++ b/tests/Appointment/fixtures/slot-calculator-signatures.php @@ -6,6 +6,12 @@ * افزودن پارامتر **اختیاری** جدید مجاز است و باید یک بار همین‌جا ثبت شود؛ تغییر نام، * تغییر نوع، تغییر ترتیب یا حذف پارامتر موجود ممنوع است. * رجوع: docs/new_feture/taskes/_shared/red-lines.md + * + * تاریخچهٔ به‌روزرسانی‌های مجاز: + * - تسک ۰۰ / قابلیت ۴: `getServiceStartTimes(..., ?int $excludeAppointmentId = null)`. + * پارامتر اختیاری با پیش‌فرض null و فقط روی مسیر **سرویسی**؛ هیچ فراخوانیِ موجودی + * رفتارش عوض نشد. لازم بود چون بدون آن، نوبتِ در حال جابه‌جایی خودش را اشغال می‌بیند + * و زمان فعلی‌اش در فهرست زمان‌های ممکن نمی‌آید. */ return [ 'explainEmptyDay' => [ @@ -52,6 +58,8 @@ return [ 'durationMinutes' => 'int', 'clinic' => '?App\Clinic\Entity\Clinic = NULL', 'forManagement' => 'bool = false', + // تسک ۰۰ / قابلیت ۴ — تنها افزودهٔ مجاز؛ رجوع به docblock بالا + 'excludeAppointmentId' => '?int = NULL', ], ], 'hasAnyAvailability' => [