get(ServiceBookingCalculator::class); } private function serviceModeDoctor(int $buffer = 0): Doctor { $owner = $this->createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر محاسبهٔ سرویس'); $this->em->persist($doctor); $schedule = $this->newWeeklySchedule($doctor, [ '0' => ['sessions' => [[ 'active' => true, 'start_time' => '09:00', 'end_time' => '17:00', 'duration_per_patient' => 20, 'location_id' => 1, ]]], ]); $schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]); $this->em->persist($schedule); $this->em->flush(); return $doctor; } private function slotModeDoctor(): Doctor { $owner = $this->createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر اسلاتی'); $this->em->persist($doctor); $schedule = $this->newWeeklySchedule($doctor, ['0' => ['sessions' => []]]); $schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SLOT]); $this->em->persist($schedule); $this->em->flush(); return $doctor; } private function service(Doctor $doctor, string $name, ?int $minutes, bool $bookable = true): ServiceItem { $section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name); $this->em->persist($section); $item = new ServiceItem($section, $name, 0); $item->setDurationMinutes($minutes)->setBookable($bookable); $this->em->persist($item); $this->em->flush(); return $item; } // ── ✅ مسیر موفق ───────────────────────────────────────────────────────── public function testSumsDurationsAndCarriesBuffer(): void { $doctor = $this->serviceModeDoctor(buffer: 10); $face = $this->service($doctor, 'لیزر صورت', 20); $bikini = $this->service($doctor, 'لیزر بیکینی', 15); $result = $this->calculator()->calculate($doctor, null, [$face->getUuid(), $bikini->getUuid()]); self::assertSame(35, $result->totalMinutes); self::assertSame(10, $result->bufferMinutes, 'بافر از meta برنامه می‌آید'); self::assertSame([], $result->warnings); self::assertCount(2, $result->serviceItems); } public function testEndForExcludesBuffer(): void { $doctor = $this->serviceModeDoctor(buffer: 10); $item = $this->service($doctor, 'مشاوره', 30); $result = $this->calculator()->calculate($doctor, null, [$item->getUuid()]); $start = 1_800_000_000; self::assertSame($start + 30 * 60, $result->endFor($start), 'بافر جزو مدت نوبت نیست'); } public function testSecretaryOverrideWinsWithoutTouchingTheService(): void { $doctor = $this->serviceModeDoctor(); $item = $this->service($doctor, 'تزریق', 20); $result = $this->calculator()->calculate( $doctor, null, [$item->getUuid()], [$item->getUuid() => 45], ); self::assertSame(45, $result->totalMinutes); $this->em->refresh($item); self::assertSame(20, $item->getDurationMinutes(), 'مدت پیش‌فرض سرویس نباید عوض شود'); } public function testIsServiceModeReadsBookingModeOnly(): void { self::assertTrue($this->calculator()->isServiceMode($this->serviceModeDoctor(), null)); self::assertFalse($this->calculator()->isServiceMode($this->slotModeDoctor(), null)); } // ── ❌ مسیرهای خطا — همان کد/پیام/فیلد قبلی کنترلر ──────────────────────── public function testServiceWithoutDurationIsRejected(): void { $doctor = $this->serviceModeDoctor(); $item = $this->service($doctor, 'بدون مدت', null); try { $this->calculator()->calculate($doctor, null, [$item->getUuid()]); self::fail('سرویس بدون مدت باید رد شود'); } catch (AppException $e) { self::assertSame(ErrorCodes::ERR_VALIDATION_001, $e->getErrorCode()); self::assertSame('مدت سرویس تعریف نشده است', $e->getMessage()); self::assertSame('service_item_uuids', $e->getField()); self::assertSame(422, $e->getHttpStatus()); } } public function testInactiveServiceIsRejectedByDefault(): void { $doctor = $this->serviceModeDoctor(); $item = $this->service($doctor, 'غیرفعال', 30, bookable: false); try { $this->calculator()->calculate($doctor, null, [$item->getUuid()]); self::fail('سرویس غیرفعال باید رد شود'); } catch (AppException $e) { self::assertSame('این سرویس برای نوبت‌دهی فعال نیست', $e->getMessage()); self::assertSame(422, $e->getHttpStatus()); } } public function testForeignTenantServiceLeaksNothing(): void { $mine = $this->serviceModeDoctor(); $other = $this->serviceModeDoctor(); $foreign = $this->service($other, 'سرویس محیط دیگر', 30); try { $this->calculator()->calculate($mine, null, [$foreign->getUuid()]); self::fail('سرویس محیط دیگر باید رد شود'); } catch (AppException $e) { // پیام نباید وجود، فعال‌بودن یا مدت سرویس را لو بدهد. self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $e->getMessage()); self::assertStringNotContainsString('سرویس محیط دیگر', $e->getMessage()); } } public function testUnknownUuidIsIndistinguishableFromForeignService(): void { $doctor = $this->serviceModeDoctor(); try { $this->calculator()->calculate($doctor, null, ['00000000-0000-4000-8000-000000000000']); self::fail('uuid ناموجود باید رد شود'); } catch (AppException $e) { self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $e->getMessage()); } } // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── public function testAllowInactiveTurnsRejectionIntoWarning(): void { $doctor = $this->serviceModeDoctor(); $item = $this->service($doctor, 'لیزر بازنشسته', 30, bookable: false); $result = $this->calculator()->calculate($doctor, null, [$item->getUuid()], allowInactive: true); self::assertSame(30, $result->totalMinutes, 'نوبت موجود باید قابل جابه‌جایی بماند'); self::assertCount(1, $result->warnings); self::assertStringContainsString('لیزر بازنشسته', $result->warnings[0]); } public function testAllowInactiveStillRejectsMissingDuration(): void { $doctor = $this->serviceModeDoctor(); $item = $this->service($doctor, 'غیرفعال و بی‌مدت', null, bookable: false); $this->expectException(AppException::class); $this->expectExceptionMessage('مدت سرویس تعریف نشده است'); $this->calculator()->calculate($doctor, null, [$item->getUuid()], allowInactive: true); } public function testEmptySelectionYieldsZeroMinutes(): void { $doctor = $this->serviceModeDoctor(buffer: 5); $result = $this->calculator()->calculate($doctor, null, []); // صفر یعنی «هیچ سرویسی انتخاب نشده»؛ رد کردنِ فهرست خالی کارِ کنترلر است، نه // این کلاس — تا مسیر PATCH بتواند نوبتِ بدون سرویس را بدون خطا رد کند. self::assertSame(0, $result->totalMinutes); self::assertSame(5, $result->bufferMinutes); } public function testNonPositiveOverrideFallsBackToServiceDuration(): void { $doctor = $this->serviceModeDoctor(); $item = $this->service($doctor, 'مشاوره', 25); foreach ([0, -10] as $bad) { $result = $this->calculator()->calculate( $doctor, null, [$item->getUuid()], [$item->getUuid() => $bad], ); self::assertSame(25, $result->totalMinutes, 'override نامعتبر نادیده گرفته می‌شود'); } } public function testDoctorWithoutScheduleFallsBackToDefaultMeta(): void { $owner = $this->createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر بی‌برنامه'); $this->em->persist($doctor); $this->em->flush(); self::assertFalse($this->calculator()->isServiceMode($doctor, null), 'پیش‌فرض slot است'); self::assertSame(0, $this->calculator()->bufferMinutes($doctor, null)); } }