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 a1dae8b3..f1ecddd5 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) -**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۵ از ۱۰ تمام شد +**وضعیت کلی:** 🔄 در حال انجام — قابلیت ۶ از ۱۰ تمام شد (بک‌اند کامل؛ مانده: UI، backfill، مستندات) **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · @@ -31,7 +31,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | ۱.۱ | `ServiceBookingCalculator` ساخته شد | ✅ | `src/Appointment/Service/ServiceBookingCalculator.php` + VO در `src/Appointment/ValueObject/ServiceBookingDuration.php` (قرارداد پروژه `ValueObject` است نه `Dto` — `src/Billing/ValueObject`، `src/Insurance/ValueObject`) | | ۱.۲ | `serviceSlots()` موجود از آن استفاده می‌کند، **خروجی‌اش عوض نشده** | ✅ | ۲۰ خط از کنترلر حذف شد. بیت‌به‌بیت‌بودن: `error()` و `ExceptionSubscriber` **یک envelope یکسان** می‌سازند (`['success'=>false,'data'=>null,'errors'=>[{code,message,field?}]]`)، پس `AppException` جای `return $this->error()` نشست. شاهد: ۱۶۶ تست موجود سبز ماند، شامل `ServiceBasedSlotsTest` و `ServiceModeSectionDurationTest` که همین مسیرهای خطا را می‌سنجند | | ۱.۳ | جمع سادهٔ `+=` **حفظ شد** (اصلاحش تسک ۰۴ است) | ✅ | با کامنت صریح در docblock کلاس: «تسک ۰۴ همان‌جا یک خط عوض می‌کند» | -| ۱.۴ | `ServiceRescheduleService` + `POST /appointment/{uuid}/service-reschedule` | ⏳ | | +| ۱.۴ | `ServiceRescheduleService` + `POST /appointment/{uuid}/service-reschedule` | ✅ | route ثبت شد (`app_appointment_appointment_servicereschedule`). کنترلر نازک: فقط ۴۰۴/۴۰۳/اعتبار `start` و ترجمهٔ دو استثنای همزمانی؛ منطق در سرویس. `forManagement` از `canManageContext()` می‌آید نه از `canManage()` — بیمارِ صاحب نوبت باید پنجرهٔ رزرو را رعایت کند | | ۱.۵ | `PATCH /appointment/{uuid}` توسعه یافت — منطق جدید داخل `isServiceMode()` | ✅ | سرویس‌ها **پیش از** بلوک زمان حل می‌شوند چون مدت به آن‌ها وابسته است. بررسی مدت فقط وقتی `$movingToLiveSlot` است (نوبت رزرو معاف — `slot_start == slot_end`). در حالت اسلاتی `$duration === null` می‌ماند و هیچ خطی اجرا نمی‌شود | | ۱.۶ | `PATCH` مقدار `service_item_uuids[]` می‌پذیرد | ✅ | وقتی فهرست کامل آمد، `service_item_uuid` تکی در حلقهٔ relations **نادیده** گرفته می‌شود (`continue`) — دو منبع برای یک چیز به نوبتِ ناسازگار می‌رسد. `allowInactive` فقط وقتی فهرست **نیامده** روشن است: نوبت موجود قفل نشود ولی افزودن سرویس غیرفعال رد شود | | ۱.۷ | ~~`ReserveConversionService` + `POST /convert-reserve`~~ → توسعهٔ `PATCH` | ✅ | **endpoint جدید ساخته نشد و توجیه نداشت** (قاعدهٔ #۸ پروژه). `PATCH` از قبل رزرو→زمان‌دار را دارد: `rescheduleTo($start,$end,$isReserve)` که خودش `refreshActiveSlotKey()` را صدا می‌زند. تست `testReserveConvertsToATimedAppointmentThroughPatch` این را می‌سنجد | @@ -40,7 +40,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | ۱.۱۰ | `Appointment::replaceServiceItems()` + `currentServiceUuids()` | ✅ | کنار `addServiceItem()` موجود؛ `currentServiceUuids()` ترتیب ورودی را حفظ می‌کند و برای نوبت قدیمیِ فقط-تکی هم uuid می‌دهد | | ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را هم‌گام می‌کند | ✅ | برخلاف `addServiceItem()` که فقط وقتی `null` است ست می‌کند، این یکی **بی‌قید** بازنویسی می‌کند (و با فهرست خالی `null` می‌شود). چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند | | ۱.۱۲ | ~~`setIsReserve()` صدا زدن `refreshActiveSlotKey()`~~ | ✅ | **موردی برای انجام نبود:** `setIsReserve()` وجود ندارد؛ toggle رزرو از قبل با `rescheduleTo($start,$end,$isReserve)` است که خودش `refreshActiveSlotKey()` را صدا می‌زند | -| ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | ⏳ | | +| ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | ✅ | `assertStartIsOffered()`. تست `testTimeOutsideTheOfferedListIsRejected` با ۰۳:۰۰ (بیرون شیفت ۰۹–۱۸) می‌سنجد — `isSlotTaken` تنها این را نمی‌گرفت | | ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ✅ | `belongsToPair()` (نه `belongsTo()`): مسیر عمومی است و کاربر احراز‌شده ندارد. جای مقایسهٔ دستیِ `getSection()->getEntityType()` نشست — نقطهٔ واحد `tenancy.md`. ⚠️ نام property باید `itemRepo` باشد وگرنه `TenantLookupInventoryTest` آن را **نمی‌شمارد** و از تور ایمنی فرار می‌کند | | ۱.۱۵ | `allowInactive` فقط برای سرویس‌های موجود نوبت، نه uuid های تازه | ✅ | پرچم روی `calculate()`؛ غیرفعال‌بودن → `warnings[]` به‌جای ۴۲۲. مدتِ نامعتبر همچنان حتی با `allowInactive` رد می‌شود (تست دارد) | | ۱.۱۶ | کنترلر نازک ماند — منطق در سرویس | ✅ | `serviceSlots()` از ۶۰ خط به ۴۰ خط؛ هیچ حلقهٔ محاسباتی در کنترلر نماند | @@ -85,7 +85,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) |---|---|---|---| | ۴.۱ | `SlotModeFrozenTest` — سه سنجه | ✅ | `OK (3 tests, 8 assertions)` | | ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ✅ | ۱۳ تست / ۲۹ assertion سبز. شامل: جمع مدت + بافر · `endFor` بدون بافر · override منشی بدون تغییر پیش‌فرض سرویس · چهار مسیر خطا با کد/پیام/فیلد دقیق · uuid ناموجود از سرویسِ محیط دیگر **قابل تفکیک نیست** · `allowInactive` → warning · فهرست خالی → صفر · override نامعتبر (۰ و منفی) → fallback · پزشک بی‌برنامه → پیش‌فرض `slot` | -| ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | ⏳ | | +| ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | ✅ | ۱۵ تست / ۳۴ assertion. شامل: جابه‌جایی بی‌ارسال مدت · حذف سرویس → کوچک‌شدن خودکار · **زمان فعلیِ خود نوبت با سرویس بلندتر پذیرفته می‌شود** (اثر `excludeAppointmentId`) · سرویس غیرفعالِ موجود → warning · حالت اسلاتی → `ERR_APPOINTMENT_004` · بیرون شیفت → `ERR_APPOINTMENT_001` · رزرو → پیام ارجاع به ویرایش · `start` غایب · سرویس بیگانه · افزودن سرویس غیرفعال تازه · گذشته · نوبت بی‌سرویس · نسخهٔ کهنه → ۴۰۹ · نوبت شخص دیگر → ۴۰۳ · uuid ناموجود → ۴۰۴ | | ۴.۴ | `PatchServiceDurationTest` — شامل «در حالت اسلاتی هیچ‌کدام اجرا نمی‌شود» | ✅ | ۹ تست / ۲۴ assertion. شامل: بازمحاسبهٔ مدت با تعویض سرویس · هم‌گامی ستون تکی · PATCH فقط-یادداشت بی‌اعتبارسنجی · رزرو معاف ولی مدت‌دار · تبدیل رزرو با همان PATCH · مدت ناسازگار → `ERR_APPOINTMENT_003` با عدد درست در پیام · سرویس بیگانه → ۴۲۲ · **حالت اسلاتی هر مدتی را می‌پذیرد و ستون سرویسی `null` می‌ماند** · نوبت سرویسیِ بی‌سرویس قفل نمی‌شود | | ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | | | ۴.۶ | `ServiceModeSectionDurationTest` موجود سبز ماند | ⏳ | | diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 68bf3d5a..709595e6 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -44,6 +44,7 @@ class AppointmentController extends BaseController private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker, private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance, private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator, + private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService, private readonly \Psr\Log\LoggerInterface $logger, ) {} @@ -1223,6 +1224,62 @@ class AppointmentController extends BaseController return $this->success(['data' => $appointment->toArray()]); } + /** + * جابه‌جایی سرویس‌آگاه — فقط حالت نوبت‌دهی سرویسی. + * + * کلاینت مدت نمی‌فرستد: `start` می‌دهد و سرور مدت را از سرویس‌های نوبت (یا فهرست + * صریحِ درخواست) حساب می‌کند. این تفاوت با `PATCH` است که کلاینت باید `slot_end` + * درست را از قبل بداند. + * + * POST /api/v1/appointment/{uuid}/service-reschedule + */ + #[Route('/api/v1/appointment/{uuid}/service-reschedule', methods: ['POST'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function serviceReschedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $appointment = $this->appointmentRepo->findByUuid($uuid); + if ($appointment === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); + } + if (!$this->canManage($appointment, $user)) { + return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $start = (int) ($data['start'] ?? 0); + if ($start <= 0) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'زمان شروع الزامی است', 422, 'start'); + } + + $serviceUuids = array_key_exists('service_item_uuids', $data) + ? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids']))) + : null; + + try { + $duration = $this->rescheduleService->reschedule( + $appointment, + $start, + $serviceUuids, + (array) ($data['durations'] ?? []), + $this->accessChecker->canManageContext($user, $appointment->getDoctor(), $appointment->getClinic()), + array_key_exists('version', $data) ? (int) $data['version'] : null, + ); + } catch (OptimisticLockException) { + return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409); + } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) { + return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'start'); + } + + return $this->success([ + 'uuid' => $appointment->getUuid(), + 'slot_start' => $appointment->getSlotStart(), + 'slot_end' => $appointment->getSlotEnd(), + 'total_duration_minutes' => $duration->totalMinutes, + 'buffer_minutes' => $duration->bufferMinutes, + 'warnings' => $duration->warnings, + ]); + } + // ── Timeline: رویدادهای یک نوبت ─────────────────────────────────────────── #[Route('/api/v1/appointment/{uuid}/events', methods: ['GET'])] diff --git a/src/Appointment/Service/ServiceRescheduleService.php b/src/Appointment/Service/ServiceRescheduleService.php new file mode 100644 index 00000000..164a1799 --- /dev/null +++ b/src/Appointment/Service/ServiceRescheduleService.php @@ -0,0 +1,132 @@ + $durationOverrides + * + * @throws AppException|OptimisticLockException + */ + public function reschedule( + Appointment $appointment, + int $start, + ?array $serviceUuids = null, + array $durationOverrides = [], + bool $forManagement = false, + ?int $expectedVersion = null, + ): ServiceBookingDuration { + $doctor = $appointment->getDoctor(); + $clinic = $appointment->getClinic(); + + if (!$this->calculator->isServiceMode($doctor, $clinic)) { + throw new AppException( + ErrorCodes::ERR_APPOINTMENT_004, + 'این نوبت در حالت نوبت‌دهی سرویسی نیست', + 422, + ); + } + + // نوبت رزرو زمان ندارد؛ تبدیلش به نوبت زمان‌دار کار `PATCH` با `is_reserve: false` است. + if ($appointment->isReserve()) { + throw new AppException( + ErrorCodes::ERR_APPOINTMENT_004, + 'نوبت رزرو زمان ندارد؛ برای تعیین زمان از ویرایش نوبت استفاده کنید', + 422, + ); + } + + if ($start < time()) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'زمان انتخابی در گذشته است', 422, 'start'); + } + + $requested = $serviceUuids ?? $appointment->currentServiceUuids(); + if ($requested === []) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_002, + 'این نوبت سرویسی ندارد؛ ابتدا سرویس آن را ثبت کنید', + 422, + 'service_item_uuids', + ); + } + + $duration = $this->calculator->calculate( + $doctor, + $clinic, + $requested, + $durationOverrides, + // سرویس‌های موجودِ نوبت ممکن است غیرفعال شده باشند؛ نوبت نباید قفل شود. + // ولی فهرستِ صریحِ درخواست باید همه فعال باشند. + allowInactive: $serviceUuids === null, + ); + + $this->assertStartIsOffered($appointment, $start, $duration->totalMinutes, $forManagement); + + $appointment->rescheduleTo($start, $duration->endFor($start)); + $appointment->replaceServiceItems($duration->serviceItems); + $appointment->setServiceDuration($duration->totalMinutes, $duration->bufferMinutes); + + if ($expectedVersion !== null) { + $this->appointments->saveWithLock($appointment, $expectedVersion); + } else { + $this->appointments->save($appointment); + } + + return $duration; + } + + /** + * زمان باید واقعاً یکی از زمان‌های پیشنهادی باشد، نه فقط «اشغال نیست». + * + * `isSlotTaken()` تنها تداخل با نوبت دیگر را می‌گوید؛ `getServiceStartTimes()` علاوه + * بر آن شیفت، تعطیلی، `date_override`، پنجرهٔ رزرو و بافر را هم اعمال می‌کند. با شرط + * اول، منشی می‌توانست نوبت را ساعت ۳ بامداد بگذارد. + */ + private function assertStartIsOffered( + Appointment $appointment, + int $start, + int $totalMinutes, + bool $forManagement, + ): void { + $offered = $this->slots->getServiceStartTimes( + $appointment->getDoctor(), + date('Y-m-d', $start), + $totalMinutes, + $appointment->getClinic(), + $forManagement, + $appointment->getId(), + ); + + if (!in_array($start, array_map('intval', array_column($offered, 'start')), true)) { + throw new AppException( + ErrorCodes::ERR_APPOINTMENT_001, + 'این زمان برای مدت انتخابی در دسترس نیست', + 422, + 'start', + ); + } + } +} diff --git a/tests/Appointment/ServiceRescheduleTest.php b/tests/Appointment/ServiceRescheduleTest.php new file mode 100644 index 00000000..ef7965fd --- /dev/null +++ b/tests/Appointment/ServiceRescheduleTest.php @@ -0,0 +1,310 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر جابه‌جایی سرویسی'); + $this->em->persist($doctor); + $this->em->flush(); + + $date = date('Y-m-d', strtotime(self::DAY_OFFSET)); + $dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7); + $schedule = $this->newWeeklySchedule($doctor, [ + $dayKey => ['sessions' => [[ + 'active' => true, 'start_time' => '09:00', 'end_time' => '18:00', + 'duration_per_patient' => 20, 'location_id' => 1, + ]]], + ]); + $schedule->setMeta(['booking_mode' => $mode, 'buffer_minutes' => $buffer]); + $this->em->persist($schedule); + + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش جابه‌جایی'); + $this->em->persist($section); + $this->em->flush(); + + return [$owner, $doctor, $section]; + } + + private function service(ServiceSection $section, string $name, int $minutes, bool $bookable = true): ServiceItem + { + $item = new ServiceItem($section, $name, 0); + $item->setDurationMinutes($minutes)->setBookable($bookable); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function at(string $time): int + { + return (int) strtotime(date('Y-m-d', strtotime(self::DAY_OFFSET)) . ' ' . $time); + } + + private function appointment(Doctor $doctor, string $time, int $minutes, array $items = []): Appointment + { + $patient = $this->createUser(['ROLE_USER']); + $start = $this->at($time); + $appt = $this->newAppointment($doctor, $patient, $start, $start + $minutes * 60); + $appt->transitionTo(Appointment::STATUS_CONFIRMED); + if ($items !== []) { + $appt->replaceServiceItems($items); + } + $this->em->persist($appt); + $this->em->flush(); + + return $appt; + } + + private function move(Appointment $appt, \App\Auth\Entity\User $actor, array $body): array + { + return $this->authJson('POST', '/api/v1/appointment/' . $appt->getUuid() . '/service-reschedule', $actor, $body); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testMovingKeepsDurationWithoutTheClientSendingIt(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر', 30); + $appt = $this->appointment($doctor, '10:00', 30, [$item]); + + $res = $this->move($appt, $owner, ['start' => $this->at('14:00')]); + + self::assertSame(200, $this->responseCode()); + self::assertSame(30, $res['data']['total_duration_minutes']); + self::assertSame($this->at('14:00'), $res['data']['slot_start']); + self::assertSame($this->at('14:30'), $res['data']['slot_end'], 'مدت را سرور حساب کرد'); + } + + public function testDroppingAServiceShrinksTheDurationAutomatically(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $face = $this->service($section, 'صورت', 20); + $bikini = $this->service($section, 'بیکینی', 15); + $appt = $this->appointment($doctor, '10:00', 35, [$face, $bikini]); + + $res = $this->move($appt, $owner, [ + 'start' => $this->at('14:00'), + 'service_item_uuids' => [$face->getUuid()], + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame(20, $res['data']['total_duration_minutes'], 'کلاینت هیچ عددی نفرستاد'); + self::assertSame($this->at('14:20'), $res['data']['slot_end']); + } + + public function testCurrentTimeIsOfferedBackToTheAppointmentItself(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $short = $this->service($section, 'کوتاه', 30); + $long = $this->service($section, 'بلند', 60); + $appt = $this->appointment($doctor, '10:00', 30, [$short]); + + // همان ساعت ۱۰:۰۰، سرویس بلندتر — بدون exclude، نوبت خودش را اشغال می‌دید. + $res = $this->move($appt, $owner, [ + 'start' => $this->at('10:00'), + 'service_item_uuids' => [$long->getUuid()], + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame(60, $res['data']['total_duration_minutes']); + self::assertSame($this->at('11:00'), $res['data']['slot_end']); + } + + public function testInactiveServiceOfAnExistingAppointmentYieldsAWarningNotAnError(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر بازنشسته', 30); + $appt = $this->appointment($doctor, '10:00', 30, [$item]); + + // کلینیک سرویس را غیرفعال می‌کند؛ نوبت موجود باید همچنان جابه‌جا شود. + $item->setBookable(false); + $this->em->flush(); + + $res = $this->move($appt, $owner, ['start' => $this->at('14:00')]); + + self::assertSame(200, $this->responseCode(), 'نوبت موجود نباید برای همیشه قفل شود'); + self::assertCount(1, $res['data']['warnings']); + self::assertStringContainsString('لیزر بازنشسته', $res['data']['warnings'][0]); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + public function testSlotModeAppointmentIsRejected(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SLOT); + $item = $this->service($section, 'سرویس', 30); + $appt = $this->appointment($doctor, '10:00', 20, [$item]); + + $res = $this->move($appt, $owner, ['start' => $this->at('14:00')]); + + self::assertSame(422, $this->responseCode()); + self::assertSame(ErrorCodes::ERR_APPOINTMENT_004, $res['errors'][0]['code']); + } + + public function testTimeOutsideTheOfferedListIsRejected(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر', 30); + $appt = $this->appointment($doctor, '10:00', 30, [$item]); + + // ۰۳:۰۰ بیرون از شیفت ۰۹:۰۰–۱۸:۰۰ است. `isSlotTaken` تنها این را نمی‌گرفت. + $res = $this->move($appt, $owner, ['start' => $this->at('03:00')]); + + self::assertSame(422, $this->responseCode()); + self::assertSame(ErrorCodes::ERR_APPOINTMENT_001, $res['errors'][0]['code']); + self::assertSame('start', $res['errors'][0]['field']); + } + + public function testReserveEntryIsRejectedWithAPointerToPatch(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر', 30); + $midnight = $this->at('00:00'); + + $patient = $this->createUser(['ROLE_USER']); + $reserve = $this->newAppointment($doctor, $patient, $midnight, $midnight); + $reserve->rescheduleTo($midnight, $midnight, true); + $reserve->replaceServiceItems([$item]); + $this->em->persist($reserve); + $this->em->flush(); + + $res = $this->move($reserve, $owner, ['start' => $this->at('14:00')]); + + self::assertSame(422, $this->responseCode()); + self::assertStringContainsString('ویرایش نوبت', $res['errors'][0]['message']); + } + + public function testMissingStartIsRejected(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر', 30); + $appt = $this->appointment($doctor, '10:00', 30, [$item]); + + $res = $this->move($appt, $owner, []); + + self::assertSame(422, $this->responseCode()); + self::assertSame('start', $res['errors'][0]['field']); + } + + public function testForeignServiceIsRejected(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $mine = $this->service($section, 'خودم', 30); + $appt = $this->appointment($doctor, '10:00', 30, [$mine]); + + [, , $otherSection] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $foreign = $this->service($otherSection, 'بیگانه', 30); + + $res = $this->move($appt, $owner, [ + 'start' => $this->at('14:00'), + 'service_item_uuids' => [$foreign->getUuid()], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $res['errors'][0]['message']); + } + + public function testAddingAnInactiveServiceExplicitlyIsRejected(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $active = $this->service($section, 'فعال', 30); + $inactive = $this->service($section, 'غیرفعال', 20, bookable: false); + $appt = $this->appointment($doctor, '10:00', 30, [$active]); + + $res = $this->move($appt, $owner, [ + 'start' => $this->at('14:00'), + 'service_item_uuids' => [$active->getUuid(), $inactive->getUuid()], + ]); + + self::assertSame(422, $this->responseCode(), 'افزودن سرویس غیرفعال تازه مجاز نیست'); + self::assertSame('این سرویس برای نوبت‌دهی فعال نیست', $res['errors'][0]['message']); + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + public function testPastStartIsRejected(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر', 30); + $appt = $this->appointment($doctor, '10:00', 30, [$item]); + + $res = $this->move($appt, $owner, ['start' => time() - 3600]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('زمان انتخابی در گذشته است', $res['errors'][0]['message']); + } + + public function testAppointmentWithoutAnyServiceIsRejectedWithAClearReason(): void + { + [$owner, $doctor] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $appt = $this->appointment($doctor, '10:00', 30); + + $res = $this->move($appt, $owner, ['start' => $this->at('14:00')]); + + self::assertSame(422, $this->responseCode()); + self::assertStringContainsString('سرویسی ندارد', $res['errors'][0]['message']); + } + + public function testStaleVersionYieldsConflict(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر', 30); + $appt = $this->appointment($doctor, '10:00', 30, [$item]); + + $res = $this->move($appt, $owner, [ + 'start' => $this->at('14:00'), + 'version' => $appt->getVersion() + 5, + ]); + + self::assertSame(409, $this->responseCode()); + self::assertSame(ErrorCodes::ERR_CONFLICT_001, $res['errors'][0]['code']); + } + + public function testAnotherUsersAppointmentIsForbidden(): void + { + [, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر', 30); + $appt = $this->appointment($doctor, '10:00', 30, [$item]); + + $stranger = $this->createUser(['ROLE_USER']); + $this->move($appt, $stranger, ['start' => $this->at('14:00')]); + + self::assertSame(403, $this->responseCode()); + } + + public function testUnknownAppointmentIsNotFound(): void + { + [$owner] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + + $this->authJson( + 'POST', + '/api/v1/appointment/00000000-0000-4000-8000-000000000000/service-reschedule', + $owner, + ['start' => $this->at('14:00')], + ); + + self::assertSame(404, $this->responseCode()); + } +}