diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 58c38ca0..68bf3d5a 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -1077,6 +1077,31 @@ class AppointmentController extends BaseController return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } + // ── حالت نوبت‌دهی سرویسی: مدت، داده است نه ورودی ────────────────────────── + // سرویس‌ها باید پیش از بلوک زمان حل شوند، چون مدتِ مجاز به آن‌ها وابسته است. + // در حالت اسلاتی هیچ‌کدام از این خطوط اجرا نمی‌شود و رفتار بیت‌به‌بیت همان می‌ماند. + $serviceMode = $this->serviceCalculator->isServiceMode($appointment->getDoctor(), $appointment->getClinic()); + $hasServiceSet = array_key_exists('service_item_uuids', $data); + $duration = null; + + if ($serviceMode) { + $requestedUuids = $hasServiceSet + ? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids']))) + : $appointment->currentServiceUuids(); + + if ($requestedUuids !== []) { + $duration = $this->serviceCalculator->calculate( + $appointment->getDoctor(), + $appointment->getClinic(), + $requestedUuids, + (array) ($data['durations'] ?? []), + // سرویسِ غیرفعالِ نوبتِ موجود نباید نوبت را برای همیشه قفل کند؛ ولی + // افزودن سرویس غیرفعالِ تازه رد می‌شود. + allowInactive: !$hasServiceSet, + ); + } + } + // Slot move / reserve toggle — both times together, or neither. $hasStart = array_key_exists('slot_start', $data); $hasEnd = array_key_exists('slot_end', $data); @@ -1091,19 +1116,45 @@ class AppointmentController extends BaseController } $isReserve = array_key_exists('is_reserve', $data) ? (bool) $data['is_reserve'] : null; $movingToLiveSlot = ($isReserve ?? $appointment->isReserve()) === false; + + // نوبتِ زمان‌دارِ سرویسی نمی‌تواند مدت دلخواه بگیرد. نوبت رزرو معاف است: + // slot_start == slot_end دارد و بازه‌ای اشغال نمی‌کند. + if ($movingToLiveSlot && $duration !== null && $newEnd !== $duration->endFor($newStart)) { + return $this->error( + ErrorCodes::ERR_APPOINTMENT_003, + sprintf('مدت این نوبت باید %d دقیقه باشد', $duration->totalMinutes), + 422, + 'slot_end', + ); + } + if ($movingToLiveSlot && ($newStart !== $appointment->getSlotStart() || $newEnd !== $appointment->getSlotEnd()) && $this->appointmentRepo->isSlotTaken($appointment->getDoctor(), $newStart, $newEnd, $appointment->getId())) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start'); } + // rescheduleTo() خودش refreshActiveSlotKey() را صدا می‌زند، پس تبدیل رزرو به + // نوبت زمان‌دار (is_reserve: false) کلید یکتایی را بازتولید می‌کند. $appointment->rescheduleTo($newStart, $newEnd, $isReserve); } + if ($duration !== null) { + if ($hasServiceSet) { + $appointment->replaceServiceItems($duration->serviceItems); + } + $appointment->setServiceDuration($duration->totalMinutes, $duration->bufferMinutes); + } + // Workflow relations — empty string clears, uuid assigns, unknown → 422. + // `service_item_uuid` تکی وقتی نادیده گرفته می‌شود که فهرست کامل آمده باشد، + // وگرنه دو منبع برای یک چیز به نوبتِ ناسازگار می‌رسد. foreach ([ 'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'], 'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'], 'staff_uuid' => [$this->staffRepo, 'setStaff', 'پرسنل'], ] as $key => [$repo, $setter, $label]) { + if ($key === 'service_item_uuid' && $hasServiceSet) { + continue; + } if (!array_key_exists($key, $data)) { continue; } diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index f3d75c1f..b4cc5c09 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -34,6 +34,10 @@ class ErrorCodes // Appointment public const ERR_APPOINTMENT_001 = 'ERR_APPOINTMENT_001'; public const ERR_APPOINTMENT_002 = 'ERR_APPOINTMENT_002'; + /** مدت نوبت با مجموع مدت سرویس‌های انتخابی نمی‌خواند (حالت نوبت‌دهی سرویسی). */ + public const ERR_APPOINTMENT_003 = 'ERR_APPOINTMENT_003'; + /** عملیات با روش نوبت‌دهیِ این محل سازگار نیست (slot در برابر service). */ + public const ERR_APPOINTMENT_004 = 'ERR_APPOINTMENT_004'; // File public const ERR_FILE_001 = 'ERR_FILE_001'; @@ -133,6 +137,8 @@ class ErrorCodes self::ERR_PAYMENT_004 => 'محیط این پرداخت مشخص نیست', self::ERR_APPOINTMENT_001 => 'اسلات انتخاب‌شده در دسترس نیست', self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست', + self::ERR_APPOINTMENT_003 => 'مدت نوبت با مجموع مدت سرویس‌های انتخابی نمی‌خواند', + self::ERR_APPOINTMENT_004 => 'این عملیات با روش نوبت‌دهی این محل سازگار نیست', self::ERR_FILE_001 => 'فرمت فایل مجاز نیست', self::ERR_FILE_002 => 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)', self::ERR_SMS_001 => 'موجودی پیامک کافی نیست', diff --git a/tests/Appointment/PatchServiceDurationTest.php b/tests/Appointment/PatchServiceDurationTest.php new file mode 100644 index 00000000..74b8a94d --- /dev/null +++ b/tests/Appointment/PatchServiceDurationTest.php @@ -0,0 +1,252 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر ویرایش'); + $this->em->persist($doctor); + $this->em->flush(); + + $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' => '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): ServiceItem + { + $item = new ServiceItem($section, $name, 0); + $item->setDurationMinutes($minutes)->setBookable(true); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + /** نوبت زمان‌دارِ تأییدشده با مدت دلخواه. */ + private function appointment(Doctor $doctor, int $minutes, array $items = []): Appointment + { + $patient = $this->createUser(['ROLE_USER']); + $start = (int) strtotime(date('Y-m-d', strtotime('+2 days')) . ' 10:00'); + $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 patch(Appointment $appt, \App\Auth\Entity\User $actor, array $body): array + { + return $this->authJson('PATCH', '/api/v1/appointment/' . $appt->getUuid(), $actor, $body + [ + 'version' => $appt->getVersion(), + ]); + } + + private function at(string $time): int + { + return (int) strtotime(date('Y-m-d', strtotime('+2 days')) . ' ' . $time); + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testReplacingServicesRecomputesTheStoredDuration(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE, buffer: 10); + $face = $this->service($section, 'لیزر صورت', 20); + $bikini = $this->service($section, 'لیزر بیکینی', 15); + $appt = $this->appointment($doctor, 20, [$face]); + + $res = $this->patch($appt, $owner, [ + 'slot_start' => $this->at('11:00'), + 'slot_end' => $this->at('11:35'), // ۳۵ = ۲۰ + ۱۵ + 'service_item_uuids' => [$face->getUuid(), $bikini->getUuid()], + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame(35, $res['data']['data']['service_total_minutes']); + self::assertSame(10, $res['data']['data']['service_buffer_minutes']); + self::assertCount(2, $res['data']['data']['service_items']); + } + + public function testTheLegacySingleServiceFollowsTheList(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $old = $this->service($section, 'سرویس قدیمی', 20); + $new = $this->service($section, 'سرویس جدید', 30); + $appt = $this->appointment($doctor, 20, [$old]); + + $res = $this->patch($appt, $owner, [ + 'slot_start' => $this->at('11:00'), + 'slot_end' => $this->at('11:30'), + 'service_item_uuids' => [$new->getUuid()], + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame($new->getUuid(), $res['data']['data']['service_item']['uuid'], + 'ستون تکی باید با فهرست هم‌گام شود، وگرنه لیست‌ها نام سرویس قدیمی را نشان می‌دهند'); + } + + public function testNoteOnlyPatchSkipsDurationValidation(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'مشاوره', 30); + // نوبتی که مدتش با سرویسش نمی‌خواند (دادهٔ قدیمی). + $appt = $this->appointment($doctor, 20, [$item]); + + $this->patch($appt, $owner, ['note' => 'یادداشت تازه']); + + self::assertSame(200, $this->responseCode(), 'PATCH بدون slot نباید مدت را بسنجد'); + } + + public function testReserveEntryStoresServicesWithoutDurationCheck(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر', 45); + $appt = $this->appointment($doctor, 30, [$item]); + $midnight = $this->at('00:00'); + + $res = $this->patch($appt, $owner, [ + 'slot_start' => $midnight, + 'slot_end' => $midnight, // رزرو: start == end + 'is_reserve' => true, + 'service_item_uuids' => [$item->getUuid()], + ]); + + self::assertSame(200, $this->responseCode(), 'نوبت رزرو از بررسی مدت معاف است'); + self::assertTrue($res['data']['data']['is_reserve']); + self::assertSame(45, $res['data']['data']['service_total_minutes'], + 'مدت برای تبدیل بعدیِ رزرو به نوبت زمان‌دار ذخیره می‌شود'); + } + + public function testReserveConvertsToATimedAppointmentThroughPatch(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر', 30); + + $patient = $this->createUser(['ROLE_USER']); + $midnight = $this->at('00:00'); + $reserve = $this->newAppointment($doctor, $patient, $midnight, $midnight); + $reserve->rescheduleTo($midnight, $midnight, true); + $reserve->replaceServiceItems([$item]); + $reserve->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($reserve); + $this->em->flush(); + + $res = $this->patch($reserve, $owner, [ + 'slot_start' => $this->at('12:00'), + 'slot_end' => $this->at('12:30'), + 'is_reserve' => false, + ]); + + self::assertSame(200, $this->responseCode(), 'تبدیل رزرو با همان PATCH انجام می‌شود — endpoint جدید لازم نیست'); + self::assertFalse($res['data']['data']['is_reserve']); + self::assertSame(30, $res['data']['data']['service_total_minutes']); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + public function testMismatchedDurationIsRejectedWithTheCorrectMinutes(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $item = $this->service($section, 'لیزر', 45); + $appt = $this->appointment($doctor, 45, [$item]); + + $res = $this->patch($appt, $owner, [ + 'slot_start' => $this->at('11:00'), + 'slot_end' => $this->at('11:20'), // ۲۰ دقیقه به‌جای ۴۵ + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame(ErrorCodes::ERR_APPOINTMENT_003, $res['errors'][0]['code']); + self::assertSame('slot_end', $res['errors'][0]['field']); + self::assertStringContainsString('45', $res['errors'][0]['message'], 'پیام باید مدت درست را بگوید'); + } + + public function testForeignServiceIsRejected(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $mine = $this->service($section, 'سرویس خودم', 30); + $appt = $this->appointment($doctor, 30, [$mine]); + + [, $otherDoctor, $otherSection] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $foreign = $this->service($otherSection, 'سرویس بیگانه', 30); + self::assertNotNull($otherDoctor->getId()); + + $res = $this->patch($appt, $owner, [ + 'slot_start' => $this->at('11:00'), + 'slot_end' => $this->at('11:30'), + 'service_item_uuids' => [$foreign->getUuid()], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $res['errors'][0]['message']); + } + + // ── ⚠️ مرزی — خط سرخ ───────────────────────────────────────────────────── + + public function testSlotModeAppointmentKeepsAcceptingAnyDuration(): void + { + [$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SLOT); + $item = $this->service($section, 'سرویس اسلاتی', 45); + $appt = $this->appointment($doctor, 20, [$item]); + + $res = $this->patch($appt, $owner, [ + 'slot_start' => $this->at('11:00'), + 'slot_end' => $this->at('11:20'), // با مدت سرویس نمی‌خواند — و باید بخورد + ]); + + self::assertSame(200, $this->responseCode(), 'حالت اسلاتی نباید هیچ اعتبارسنجی سرویسی بگیرد'); + self::assertNull($res['data']['data']['service_total_minutes'], 'ستون سرویسی در حالت اسلاتی null می‌ماند'); + } + + public function testServiceModeAppointmentWithoutAnyServiceIsNotBlocked(): void + { + [$owner, $doctor] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE); + $appt = $this->appointment($doctor, 20); // دادهٔ قدیمی: بدون سرویس + + $res = $this->patch($appt, $owner, [ + 'slot_start' => $this->at('11:00'), + 'slot_end' => $this->at('11:20'), + ]); + + self::assertSame(200, $this->responseCode(), 'نوبت سرویسی بدون سرویس نباید قفل شود'); + self::assertNull($res['data']['data']['service_total_minutes']); + } +}