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']); } // ── جداسازی محیط ──────────────────────────────────────────────────────── 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); } } }