createUser(['ROLE_USER', 'ROLE_CLINIC']); $clinic = new Clinic($user); $clinic->setName('کلینیک دوره'); $this->em->persist($clinic); $this->em->flush(); $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); $this->em->persist($section); $address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address); $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); $doctor = new Doctor($doctorUser, 'دکتر دوره'); $this->em->persist($doctor); $patientUser = $this->createUser(['ROLE_USER']); $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); $this->em->persist($patient); $this->em->flush(); return [$user, $section, $address, $doctor, $patient]; } private function service(ServiceSection $section, string $name = 'لیزر فول‌بادی'): ServiceItem { $item = new ServiceItem($section, $name); $item->setSoloDurationMinutes(30); $item->setPriceRials(5_000_000); $this->em->persist($item); $this->em->flush(); return $item; } /** @param array $extra */ private function protocol(User $user, ServiceItem $service, array $extra = []): array { $body = $this->authJson('POST', '/api/v1/course-protocols', $user, $extra + [ 'service_uuid' => $service->getUuid(), 'session_count' => 8, 'min_days' => 21, 'ideal_days' => 28, 'max_days' => 45, 'steps' => [ ['session_number' => 1, 'params' => ['energy' => 12]], ['session_number' => 2, 'params' => ['energy' => 14]], ['session_number' => 3, 'params' => ['energy' => 16]], ['session_number' => 4, 'params' => ['energy' => 18]], ], ]); self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); return $body['data']; } private function startCourse(User $user, PatientRecord $patient, string $protocolUuid): array { $body = $this->authJson('POST', '/api/v1/treatment-course', $user, [ 'patient_uuid' => $patient->getUuid(), 'protocol_uuid' => $protocolUuid, ]); self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); return $body['data']; } private function courseEntity(string $uuid): TreatmentCourse { return static::getContainer()->get(TreatmentCourseRepository::class)->findByUuid($uuid); } private function linker(): CourseSessionLinker { return static::getContainer()->get(CourseSessionLinker::class); } // ── پروتکل ────────────────────────────────────────────────────────────── public function testProtocolKeepsItsStepsAndSpacing(): void { [$user, $section] = $this->clinicWithPatient(); $protocol = $this->protocol($user, $this->service($section)); self::assertSame(8, $protocol['session_count']); self::assertSame([21, 28, 45], [$protocol['min_days'], $protocol['ideal_days'], $protocol['max_days']]); self::assertSame([1, 2, 3, 4], array_column($protocol['steps'], 'session_number')); self::assertSame(16, $protocol['steps'][2]['params']['energy']); } /** ترتیب فاصله‌ها معنا دارد؛ حداکثر کوچک‌تر از حداقل یعنی پروتکل غیرقابل اجرا. */ public function testSpacingMustBeOrdered(): void { [$user, $section] = $this->clinicWithPatient(); $this->authJson('POST', '/api/v1/course-protocols', $user, [ 'service_uuid' => $this->service($section)->getUuid(), 'session_count' => 6, 'min_days' => 30, 'ideal_days' => 20, 'max_days' => 45, ]); self::assertSame(422, $this->responseCode()); } /** دورهٔ یک‌جلسه‌ای همان نوبت تکی است. */ public function testASingleSessionCourseIsRejected(): void { [$user, $section] = $this->clinicWithPatient(); $this->authJson('POST', '/api/v1/course-protocols', $user, [ 'service_uuid' => $this->service($section)->getUuid(), 'session_count' => 1, 'min_days' => 7, 'ideal_days' => 7, 'max_days' => 14, ]); self::assertSame(422, $this->responseCode()); } public function testOneProtocolPerService(): void { [$user, $section] = $this->clinicWithPatient(); $service = $this->service($section); $this->protocol($user, $service); $this->authJson('POST', '/api/v1/course-protocols', $user, [ 'service_uuid' => $service->getUuid(), 'session_count' => 4, 'min_days' => 7, 'ideal_days' => 14, 'max_days' => 21, ]); self::assertSame(422, $this->responseCode()); } // ── شروع دوره ─────────────────────────────────────────────────────────── public function testStartingACourseCreatesEverySessionWithItsParams(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $protocol = $this->protocol($user, $this->service($section)); $course = $this->startCourse($user, $patient, $protocol['uuid']); self::assertCount(8, $course['sessions']); self::assertSame(array_fill(0, 8, 'planned'), array_column($course['sessions'], 'status')); self::assertSame(12, $course['sessions'][0]['params']['energy']); self::assertSame(18, $course['sessions'][3]['params']['energy']); // جلسات ۵ تا ۸ پارامتری در پروتکل ندارند — آرایهٔ خالی، نه خطا. self::assertSame([], (array) $course['sessions'][7]['params']); self::assertSame( ['completed' => 0, 'booked' => 0, 'planned' => 8, 'skipped' => 0, 'total' => 8], array_intersect_key($course['progress'], array_flip(['completed', 'booked', 'planned', 'skipped', 'total'])), ); self::assertSame(1, $course['progress']['next_session_number']); } /** ⭐ تغییر پروتکل نباید دورهٔ در جریان را عوض کند. */ public function testChangingTheProtocolLeavesRunningCoursesAlone(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $protocol = $this->protocol($user, $this->service($section)); $course = $this->startCourse($user, $patient, $protocol['uuid']); $this->authJson('PATCH', "/api/v1/course-protocol/{$protocol['uuid']}", $user, [ 'session_count' => 12, 'min_days' => 30, 'ideal_days' => 40, 'max_days' => 60, ]); self::assertSame(200, $this->responseCode()); $after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; self::assertSame(8, $after['session_count']); self::assertSame([21, 28, 45], [$after['min_days'], $after['ideal_days'], $after['max_days']]); self::assertCount(8, $after['sessions']); } public function testASecondActiveCourseForTheSameServiceIsRejected(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $protocol = $this->protocol($user, $this->service($section)); $first = $this->startCourse($user, $patient, $protocol['uuid']); $body = $this->authJson('POST', '/api/v1/treatment-course', $user, [ 'patient_uuid' => $patient->getUuid(), 'protocol_uuid' => $protocol['uuid'], ]); self::assertSame(422, $this->responseCode()); // پیام باید شناسهٔ دورهٔ موجود را بدهد تا اپراتور بتواند برود سراغش. self::assertStringContainsString($first['uuid'], $body['errors'][0]['message']); } /** رهاکردن دوره جا را برای دورهٔ تازه باز می‌کند. */ public function testAbandoningACourseFreesTheSlotForANewOne(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $protocol = $this->protocol($user, $this->service($section)); $course = $this->startCourse($user, $patient, $protocol['uuid']); $this->authJson('POST', "/api/v1/treatment-course/{$course['uuid']}/abandon", $user, ['reason' => '']); self::assertSame(422, $this->responseCode(), 'رهاکردن بدون دلیل نباید پذیرفته شود'); $abandoned = $this->authJson('POST', "/api/v1/treatment-course/{$course['uuid']}/abandon", $user, [ 'reason' => 'انصراف بیمار', ]); self::assertSame(200, $this->responseCode()); self::assertSame('abandoned', $abandoned['data']['status']); $this->startCourse($user, $patient, $protocol['uuid']); } // ── پیشرفت و لنگر متحرک ───────────────────────────────────────────────── public function testProgressCountsCompletedSessionsAndPointsAtTheNextOne(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $protocol = $this->protocol($user, $service); $course = $this->startCourse($user, $patient, $protocol['uuid']); $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 3); $after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; self::assertSame(3, $after['progress']['completed']); self::assertSame(8, $after['progress']['total']); self::assertSame(4, $after['progress']['next_session_number']); self::assertSame(18, $after['progress']['next_params']['energy']); } /** ⭐ فاصله از آخرین جلسهٔ **انجام‌شده** حساب می‌شود، نه از شروع دوره. */ public function testTheSuggestionAnchorsOnTheLastCompletedSession(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $protocol = $this->protocol($user, $service); $course = $this->startCourse($user, $patient, $protocol['uuid']); $completedAt = time() - 10 * 86400; $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 3, $completedAt); $body = $this->authJson( 'GET', "/api/v1/treatment-course/{$course['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}", $user, ); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); $data = $body['data']; self::assertSame(4, $data['session_number']); self::assertSame(18, $data['params']['energy']); self::assertSame($completedAt + 21 * 86400, $data['range']['min']); self::assertSame($completedAt + 45 * 86400, $data['range']['max']); self::assertSame($completedAt + 28 * 86400, $data['ideal_at']); self::assertNull($data['warning']); } public function testPassingTheMaximumGapProducesAWarning(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $protocol = $this->protocol($user, $service); $course = $this->startCourse($user, $patient, $protocol['uuid']); $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1, time() - 50 * 86400); $data = $this->authJson( 'GET', "/api/v1/treatment-course/{$course['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}", $user, )['data']; self::assertNotNull($data['warning']); self::assertStringContainsString('45', $data['warning'], 'پیام باید حداکثر فاصلهٔ همان دوره را بگوید'); } // ── لغو یک جلسهٔ وسط دوره ─────────────────────────────────────────────── /** ⭐ لغو یک جلسه فقط همان جلسه را برمی‌گرداند؛ بقیهٔ دوره دست‌نخورده. */ public function testCancellingOneSessionOnlyResetsThatSession(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $protocol = $this->protocol($user, $service); $course = $this->startCourse($user, $patient, $protocol['uuid']); $entity = $this->courseEntity($course['uuid']); $sessions = $entity->getSessions()->toArray(); usort($sessions, static fn (CourseSession $a, CourseSession $b): int => $a->getSessionNumber() <=> $b->getSessionNumber()); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId()); $this->linker()->link($sessions[0], $appointment); $second = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId()); $this->linker()->link($this->reloadSession($sessions[1]->getUuid()), $second); $before = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; self::assertSame(['booked', 'booked'], array_slice(array_column($before['sessions'], 'status'), 0, 2)); self::assertTrue($this->linker()->unlink($this->reloadAppointment($appointment->getUuid()))); $after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']; self::assertSame('planned', $after['sessions'][0]['status']); self::assertSame('booked', $after['sessions'][1]['status'], 'بقیهٔ جلسات نباید دست بخورند'); } public function testTheCourseCompletesOnlyWhenEverySessionIsDone(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); // پروتکل کوتاه تا کل دوره در تست تمام شود. $protocol = $this->authJson('POST', '/api/v1/course-protocols', $user, [ 'service_uuid' => $service->getUuid(), 'session_count' => 2, 'min_days' => 7, 'ideal_days' => 14, 'max_days' => 21, ])['data']; $course = $this->startCourse($user, $patient, $protocol['uuid']); $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1); self::assertSame('active', $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']['status']); $this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1); self::assertSame('completed', $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']['status']); } // ── جداسازی محیط ──────────────────────────────────────────────────────── /** * ⭐ «سخت‌گیرانه‌تر برنده»: قانون `spacing` کلینیک با پروتکل دوره نمی‌جنگد. * * پروتکل ۷ روز می‌گوید و قانون ۲۱ روز؛ فاصلهٔ مؤثر باید ۲۱ باشد. اگر پروتکل برنده * می‌شد، قانونِ ایمنی کلینیک با تعریف یک پروتکل کوتاه دور زده می‌شد. */ public function testTheStricterOfProtocolAndSpacingPolicyWins(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $service = $this->service($section); $protocol = $this->protocol($user, $service, ['min_days' => 7, 'ideal_days' => 10, 'max_days' => 20]); $started = $this->startCourse($user, $patient, $protocol['uuid']); $course = $this->courseEntity($started['uuid']); $scheduler = static::getContainer()->get(\App\Course\Service\CourseScheduler::class); self::assertSame(7, $scheduler->effectiveMinDays($course), 'بدون قانون، پروتکل حاکم است'); $policy = new \App\Policy\Entity\Policy( $course->getEntityType(), $course->getEntityId(), \App\Policy\Entity\Policy::CATEGORY_SPACING, 'حداقل ۲۱ روز بین جلسات لیزر', ); $policy->setCondition(['match' => 'all', 'conditions' => []]); $policy->setEffects([['type' => 'min_days_between', 'value' => 21]]); $policy->setActive(true); $this->em->persist($policy); $this->em->flush(); $this->em->clear(); self::assertSame( 21, $scheduler->effectiveMinDays($this->courseEntity($started['uuid'])), 'قانون سخت‌گیرتر برنده است', ); } /** * ⭐ سقف افق: جلسه‌ای که حتی حداقلِ فاصله‌اش بیرون ۹۰ روز می‌افتد **رد** می‌شود، نه * اینکه `book-all` را بشکند. جلسات بیرون بازه `planned` می‌مانند تا بعداً رزرو شوند. */ public function testSessionsBeyondTheHorizonAreSkippedNotFailed(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $service = $this->service($section); // فاصلهٔ ۶۰ روزه با ۸ جلسه: جلسهٔ سوم به بعد بیرون افق ۹۰ روزه است. $protocol = $this->protocol($user, $service, ['min_days' => 60, 'ideal_days' => 60, 'max_days' => 70]); $started = $this->startCourse($user, $patient, $protocol['uuid']); $course = $this->courseEntity($started['uuid']); $scheduler = static::getContainer()->get(\App\Course\Service\CourseScheduler::class); $now = time(); $minDays = $scheduler->effectiveMinDays($course, $now); $horizon = $now + \App\Course\Service\CourseScheduler::SEARCH_HORIZON_DAYS * 86400; // لنگر دوم = لنگر اول + ۶۰ روز؛ سومی از افق می‌گذرد. $third = $now + 3 * $minDays * 86400; self::assertGreaterThan($horizon, $third, 'جلسهٔ سوم باید بیرون افق باشد'); self::assertSame(60, $minDays); // خودِ دوره دست‌نخورده می‌ماند: هیچ جلسه‌ای حذف نمی‌شود. self::assertCount(8, $course->getSessions()->toArray()); } /** * ⭐ `book-all` همه یا هیچ است. * * تقویم فقط یک روزِ هفته باز است و فاصلهٔ پروتکل ۱ تا ۲ روز؛ پس جلسهٔ اول وقت پیدا * می‌کند و جلسهٔ دوم نه. اگر تراکنش کار نکند، بیمار با یک نوبتِ تنها از یک دورهٔ * هشت‌جلسه‌ای می‌ماند و هیچ‌کس نمی‌فهمد کجا قطع شد. */ public function testAFailedBookAllLeavesEverySessionPlanned(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ 'address_uuid' => $address->getUuid(), 'code' => 'room', 'name' => 'اتاق', ]); self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); $resource = $this->authJson('POST', '/api/v1/resource', $user, [ 'address_uuid' => $address->getUuid(), 'type_uuid' => $type['data']['uuid'], 'name' => 'اتاق ۱', ]); self::assertSame(201, $this->responseCode()); // فقط شنبه‌ها باز است. $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [ 'days' => [6 => [['start_minute' => 540, 'end_minute' => 1020]]], ]); self::assertSame(200, $this->responseCode()); $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [ 'segments' => [ ['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]], ], ]); self::assertSame(200, $this->responseCode()); // بازهٔ ۱ تا ۲ روز: جلسهٔ دوم حتماً بیرون تنها روزِ باز می‌افتد. $protocol = $this->protocol($user, $service, ['min_days' => 1, 'ideal_days' => 1, 'max_days' => 2]); $started = $this->startCourse($user, $patient, $protocol['uuid']); $body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [ 'branch_uuid' => $address->getUuid(), 'doctor_uuid' => $doctor->getUuid(), ]); self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); $this->em->clear(); $course = $this->courseEntity($started['uuid']); foreach ($course->getSessions() as $session) { self::assertSame( CourseSession::STATUS_PLANNED, $session->getStatus(), sprintf('جلسهٔ %d نباید رزرو مانده باشد', $session->getSessionNumber()), ); self::assertNull($session->getAppointment()); } } /** * ⭐ مسیر **موفق** `book-all`: لنگر بعد از هر رزرو جلو می‌رود. * * تست شکست از قبل بود؛ این یکی همان چیزی را می‌سنجد که کار می‌کند. لنگر ثابت یعنی * هر هشت جلسه دور همان تاریخ جمع می‌شوند و پروتکل عملاً بی‌اثر است. */ public function testBookAllMovesTheAnchorForwardBetweenSessions(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ 'address_uuid' => $address->getUuid(), 'code' => 'room', 'name' => 'اتاق', ]); self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); $resource = $this->authJson('POST', '/api/v1/resource', $user, [ 'address_uuid' => $address->getUuid(), 'type_uuid' => $type['data']['uuid'], 'name' => 'اتاق دوره', ]); self::assertSame(201, $this->responseCode()); // هر روز باز — تا جلسات فقط با فاصلهٔ پروتکل جدا شوند، نه با تعطیلی. $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [ 'days' => array_fill_keys(range(0, 6), [['start_minute' => 480, 'end_minute' => 1200]]), ]); self::assertSame(200, $this->responseCode()); $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [ 'segments' => [ ['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]], ], ]); self::assertSame(200, $this->responseCode()); $protocol = $this->protocol($user, $service, [ 'session_count' => 3, 'min_days' => 7, 'ideal_days' => 7, 'max_days' => 14, 'steps' => [ ['session_number' => 1, 'params' => ['energy' => 12]], ['session_number' => 2, 'params' => ['energy' => 14]], ['session_number' => 3, 'params' => ['energy' => 16]], ], ]); $started = $this->startCourse($user, $patient, $protocol['uuid']); $body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [ 'branch_uuid' => $address->getUuid(), 'doctor_uuid' => $doctor->getUuid(), ]); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertSame(3, $body['data']['booked']); $this->em->clear(); $sessions = $this->courseEntity($started['uuid'])->getSessions()->toArray(); usort($sessions, static fn (CourseSession $a, CourseSession $b): int => $a->getSessionNumber() <=> $b->getSessionNumber()); $starts = array_map( static fn (CourseSession $s): ?int => $s->getAppointment()?->getSlotStart(), $sessions, ); self::assertNotContains(null, $starts, 'هر سه جلسه باید نوبت گرفته باشند'); // لنگر متحرک: هر جلسه دست‌کم هفت روز بعد از جلسهٔ قبلی است. for ($i = 1; $i < count($starts); $i++) { $gapDays = (int) floor(($starts[$i] - $starts[$i - 1]) / 86400); self::assertGreaterThanOrEqual(7, $gapDays, sprintf( 'فاصلهٔ جلسهٔ %d با قبلی %d روز شد', $i + 1, $gapDays, )); } } public function testAnotherClinicCannotSeeTheCourse(): void { [$owner, $section, , , $patient] = $this->clinicWithPatient(); [$other] = $this->clinicWithPatient(); $protocol = $this->protocol($owner, $this->service($section)); $course = $this->startCourse($owner, $patient, $protocol['uuid']); $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $other); self::assertSame(404, $this->responseCode()); } // ── کمکی ──────────────────────────────────────────────────────────────── private function reloadSession(string $uuid): CourseSession { return static::getContainer()->get(CourseSessionRepository::class)->findByUuid($uuid); } private function reloadAppointment(string $uuid): \App\Appointment\Entity\Appointment { return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) ->getRepository(\App\Appointment\Entity\Appointment::class) ->findOneBy(['uuid' => $uuid]); } private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): \App\Appointment\Entity\Appointment { $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); $start = time() + 86400 + (++$this->slotCursor) * 3600; $appointment = new \App\Appointment\Entity\Appointment( $em->getRepository(Doctor::class)->find($doctor->getId()), $em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(), $start, $start + 1800, ); $appointment->assignTenantPair('clinic', $clinicId); $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId())); $appointment->setPatientName('بیمار دوره'); $em->persist($appointment); $em->flush(); return $appointment; } /** n جلسهٔ بعدی را رزرو و انجام‌شده می‌کند. */ private function completeSessions( string $courseUuid, Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId, int $count, ?int $completedAt = null, ): void { for ($i = 0; $i < $count; $i++) { $course = $this->courseEntity($courseUuid); $sessions = $course->plannedSessions(); usort($sessions, static fn (CourseSession $a, CourseSession $b): int => $a->getSessionNumber() <=> $b->getSessionNumber()); $appointment = $this->appointment($doctor, $patient, $service, $clinicId); $this->linker()->link($this->reloadSession($sessions[0]->getUuid()), $appointment); $this->linker()->complete($this->reloadAppointment($appointment->getUuid()), $completedAt); } } }