sessions = $this->em->getRepository(TreatmentSession::class); $this->scheduler = new TreatmentScheduler($this->sessions); } /** سناریوی بوتاکس: جلسه ۲ بعد از ۱۵ روز، بقیه ماهانه. */ private function botoxCase(): TreatmentCase { $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); $clinic = new Clinic($user); $clinic->setName('کلینیک بوتاکس'); $this->em->persist($clinic); $this->em->flush(); $section = new ServiceSection('clinic', (int) $clinic->getId(), 'تزریق'); $this->em->persist($section); $category = new CatalogCategory('clinic', (int) $clinic->getId(), 'پیشانی'); $this->em->persist($category); $service = new ServiceItem($section, 'بوتاکس پیشانی', 8_000_000); $service->setCatalogCategory($category); $this->em->persist($service); $staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور'); $this->em->persist($staff); $protocol = new TreatmentProtocol($service); $this->em->persist($protocol); $protocol->replaceSteps([ new TreatmentProtocolStep($protocol, 1, 0), new TreatmentProtocolStep($protocol, 2, 15), new TreatmentProtocolStep($protocol, 3, 30), new TreatmentProtocolStep($protocol, 4, 30), ]); $protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]); $this->em->flush(); $record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId()); $this->em->persist($record); $this->em->flush(); $opener = new TreatmentCaseOpener( new CategoryClosureResolver($this->em->getRepository(CatalogCategoryInclude::class)), $this->em->getRepository(CatalogCategory::class), $this->em, ); return $opener->open('clinic', (int) $clinic->getId(), $record, $service, $protocol); } /** @return TreatmentSession[] */ private function orderedSessions(TreatmentCase $case): array { $sessions = $case->getSessions()->toArray(); usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int => $a->getSessionNumber() <=> $b->getSessionNumber()); return $sessions; } private function finish(TreatmentSession $session, int $at): void { $session->finish(); // finish() زمان واقعی می‌گذارد؛ تست تاریخ را کنترل‌شده جا می‌اندازد. $ref = new \ReflectionProperty(TreatmentSession::class, 'finishedAt'); $ref->setValue($session, $at); $this->em->flush(); } public function testUnevenIntervalsAreMeasuredFromTheLastFinishedSession(): void { $case = $this->botoxCase(); $sessions = $this->orderedSessions($case); $day1 = 1_800_000_000; $this->finish($sessions[0], $day1); $next = $this->scheduler->scheduleNextAfter($sessions[0]); self::assertSame($sessions[1]->getId(), $next?->getId()); self::assertSame($day1 + 15 * self::DAY, $sessions[1]->getDueAt()); // بیمار ۲۰ روز دیر آمد — جلسهٔ سوم باید از همین تاریخ حساب شود، نه از شروع دوره. $actual = $sessions[1]->getDueAt() + 20 * self::DAY; $this->finish($sessions[1], $actual); $this->scheduler->scheduleNextAfter($sessions[1]); self::assertSame($actual + 30 * self::DAY, $sessions[2]->getDueAt()); } /** با لنگر ثابت، بیمارِ دیرآمده جلسهٔ بعدی‌اش را زودتر از موعد می‌گرفت. */ public function testALateSessionShiftsTheRestOfTheCourse(): void { $case = $this->botoxCase(); $sessions = $this->orderedSessions($case); $day1 = 1_800_000_000; $this->finish($sessions[0], $day1); $this->scheduler->scheduleNextAfter($sessions[0]); $late = $day1 + 60 * self::DAY; $this->finish($sessions[1], $late); $this->scheduler->scheduleNextAfter($sessions[1]); self::assertSame($late + 30 * self::DAY, $sessions[2]->getDueAt()); self::assertGreaterThan($day1 + 45 * self::DAY, $sessions[2]->getDueAt()); } /** فقط جلسهٔ بعدی بازمحاسبه می‌شود؛ جلسات دورتر هنوز لنگری ندارند. */ public function testOnlyTheNextSessionIsRescheduled(): void { $case = $this->botoxCase(); $sessions = $this->orderedSessions($case); $this->finish($sessions[0], 1_800_000_000); $this->scheduler->scheduleNextAfter($sessions[0]); self::assertNotNull($sessions[1]->getDueAt()); self::assertNull($sessions[2]->getDueAt()); self::assertNull($sessions[3]->getDueAt()); } /** غیبت جلسه را نمی‌سوزاند: تعداد جلسات ثابت می‌ماند و همان جلسه دوباره برنامه‌ریزی می‌شود. */ public function testNoShowKeepsTheSessionAndTheTotal(): void { $case = $this->botoxCase(); $sessions = $this->orderedSessions($case); $this->finish($sessions[0], 1_800_000_000); $this->scheduler->scheduleNextAfter($sessions[0]); $sessions[1]->markNoShow(); $this->em->flush(); self::assertSame(4, $case->getTotalSessions()); self::assertCount(4, $case->getSessions()); self::assertSame(TreatmentSession::STATUS_NO_SHOW, $sessions[1]->getStatus()); // همان جلسه دوباره در صف رزرو می‌آید، نه جلسه‌ای تازه. self::assertSame($sessions[1]->getId(), $this->sessions->findNextUnbooked($case)?->getId()); } public function testLastSessionSchedulesNothing(): void { $case = $this->botoxCase(); $sessions = $this->orderedSessions($case); foreach ($sessions as $index => $session) { $this->finish($session, 1_800_000_000 + $index * 30 * self::DAY); } self::assertNull($this->scheduler->scheduleNextAfter($sessions[3])); } public function testUnfinishedSessionSchedulesNothing(): void { $case = $this->botoxCase(); $sessions = $this->orderedSessions($case); self::assertNull($this->scheduler->scheduleNextAfter($sessions[0])); self::assertNull($sessions[1]->getDueAt()); } }