From b589a851d0416efc1b597cc485ab5d85c58b80f6 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 12:57:48 +0330 Subject: [PATCH] feat(booking): make PATCH derive appointment duration from its services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In service mode PATCH accepted any duration and only updated the single service_item column while the service_items collection stayed untouched, so an edit could leave an appointment with old services and a new length. A 45-minute service could be shortened to 20 and the next patient would sit on top of it. Services are now resolved before the time block (duration depends on them) and the stored end must equal start + total minutes. Reserve entries are exempt: they carry slot_start == slot_end and occupy no interval, but they do store the computed duration so a later conversion does not lose it. No convert-reserve endpoint was added: PATCH already converts a reserve to a timed appointment via rescheduleTo($start, $end, $isReserve), which refreshes active_slot_key itself. Project rule 8 — a new endpoint needs an existing one to be insufficient even after extension. Slot mode is untouched: with booking_mode = slot the duration stays null and not one of the new branches runs. Covered by an explicit test. New error codes are ERR_APPOINTMENT_003/004 (the file only had 001/002). Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) --- .../Controller/AppointmentController.php | 51 ++++ src/Shared/Constant/ErrorCodes.php | 6 + .../Appointment/PatchServiceDurationTest.php | 252 ++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 tests/Appointment/PatchServiceDurationTest.php 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']); + } +}