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(4_000_000); $this->em->persist($item); $this->em->flush(); return $item; } /** @param array $body */ private function savePolicy(User $user, array $body): array { $saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, $body); self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE)); return $saved['data']; } /** نوبتی در آینده، با مبلغ ثبت‌شده و در صورت نیاز پرداخت موفق. */ private function appointment( Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId, int $hoursAhead, int $price = 4_000_000, int $paid = 0, ): Appointment { $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); $start = time() + $hoursAhead * 3600 + (++$this->slotCursor) * 60; $appointment = new 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->setVisitPriceRials($price); $appointment->setPatientName('بیمار لغو'); $appointment->transitionTo(Appointment::STATUS_CONFIRMED); $em->persist($appointment); $em->flush(); if ($paid > 0) { $payment = new Payment($appointment->getUser(), $paid, 'zarinpal', Payment::TYPE_APPOINTMENT); $payment->assignTenantPair('clinic', $clinicId); $payment->setAppointment($appointment); $payment->setStatus(Payment::STATUS_SUCCESS); $em->persist($payment); $em->flush(); } return $appointment; } private function wallet(): WalletService { return static::getContainer()->get(WalletService::class); } // ── پیش‌نمایش ─────────────────────────────────────────────────────────── public function testInsideTheFreeWindowThereIsNoPenalty(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $this->savePolicy($user, [ 'free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50, 'deposit_refundable' => false, ]); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 48, paid: 4_000_000); $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user); self::assertSame(200, $this->responseCode(), json_encode($preview, JSON_UNESCAPED_UNICODE)); self::assertSame(0, $preview['data']['penalty_rials']); self::assertTrue($preview['data']['deposit_refundable']); self::assertTrue($preview['data']['within_free_window']); } public function testOutsideTheFreeWindowThePercentagePenaltyApplies(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $this->savePolicy($user, [ 'free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50, 'deposit_refundable' => false, ]); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 6, paid: 4_000_000); $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; self::assertSame(2_000_000, $preview['penalty_rials']); self::assertFalse($preview['deposit_refundable']); self::assertFalse($preview['within_free_window']); } /** ⭐ لغو توسط کلینیک هرگز جریمه ندارد، حتی یک ساعت مانده به نوبت. */ public function testTheClinicCancellingItsOwnAppointmentIsAlwaysFree(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 100]); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 1, paid: 4_000_000); $preview = $this->authJson( 'GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview?by=doctor", $user, )['data']; self::assertSame(0, $preview['penalty_rials']); self::assertTrue($preview['deposit_refundable']); } /** ⭐ جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ لغو نیست. */ public function testThePenaltyNeverExceedsWhatWasPaid(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $this->savePolicy($user, [ 'free_window_hours' => 24, 'penalty_mode' => 'fixed', 'penalty_value' => 9_000_000, ]); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2, paid: 1_000_000); $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; self::assertSame(1_000_000, $preview['penalty_rials']); self::assertNotEmpty($preview['notes']); } /** نوبت نقدی: جریمه صفر می‌شود و پاسخ توضیحش را می‌دهد. */ public function testAnUnpaidAppointmentIsNotCharged(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50]); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2); $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; self::assertSame(0, $preview['penalty_rials']); self::assertStringContainsString('پرداختی نداشته', implode(' ', $preview['notes'])); } public function testWithoutAPolicyNothingIsCharged(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 1, paid: 4_000_000); $preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data']; self::assertSame(0, $preview['penalty_rials']); } // ── لغو واقعی ─────────────────────────────────────────────────────────── public function testCancellingChargesTheWalletAndReleasesTheSlot(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 25]); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 3, paid: 4_000_000); // کیف پول باید موجودی داشته باشد وگرنه جریمه کسر نمی‌شود. $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); $this->wallet()->charge($em->getRepository(\App\Auth\Entity\User::class)->find($appointment->getUser()->getId()), 5_000_000); $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertSame(1_000_000, $body['data']['penalty_rials']); self::assertTrue($body['data']['penalty_charged']); self::assertSame('cancelled_by_user', $body['data']['status']); $patientUser = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) ->getRepository(\App\Auth\Entity\User::class) ->find($appointment->getUser()->getId()); self::assertSame(4_000_000, $this->wallet()->balance($patientUser), 'جریمه باید از کیف پول کسر شود'); } /** موجودی ناکافی نباید لغو را شکست بدهد؛ نوبت باید آزاد شود. */ public function testAnEmptyWalletDoesNotBlockTheCancellation(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50]); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 3, paid: 4_000_000); $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); self::assertSame(200, $this->responseCode()); self::assertSame(2_000_000, $body['data']['penalty_rials']); self::assertFalse($body['data']['penalty_charged'], 'موجودی نبود، پس کسر نشد — ولی نوبت لغو شد'); self::assertSame('cancelled_by_user', $body['data']['status']); } public function testCancellingTwiceIsRejected(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 30); $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); self::assertSame(200, $this->responseCode()); $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); self::assertSame(409, $this->responseCode()); } /** برای گذشته `no_show` یا `completed` معنا دارد، نه لغو. */ public function testAPastAppointmentCannotBeCancelled(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 5); $this->em->getConnection()->executeStatement( 'UPDATE appointments SET slot_start = ?, slot_end = ? WHERE uuid = ?', [time() - 7200, time() - 5400, $appointment->getUuid()], ); $this->em->clear(); $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user); self::assertSame(422, $this->responseCode()); } // ── عدم حضور ──────────────────────────────────────────────────────────── /** ⭐ سومین عدم حضور برچسب پرریسک می‌گذارد — ولی بیمار را مسدود نمی‌کند. */ public function testTheThirdNoShowTagsThePatient(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $tag = new TenantTag('clinic', (int) $address->getClinicId(), 'پرریسک', '#dc2626'); $this->em->persist($tag); $this->em->flush(); $this->savePolicy($user, ['no_show_threshold' => 3, 'risk_tag_uuid' => $tag->getUuid()]); $last = null; for ($i = 1; $i <= 3; $i++) { $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2); $last = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); self::assertSame(200, $this->responseCode(), json_encode($last, JSON_UNESCAPED_UNICODE)); } self::assertSame(3, $last['data']['count']); self::assertTrue($last['data']['tagged']); $reloaded = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) ->getRepository(PatientRecord::class) ->find($patient->getId()); $tagNames = array_map(static fn (TenantTag $t): string => $t->getName(), $reloaded->getTags()->toArray()); self::assertContains('پرریسک', $tagNames); } /** ثبت دوباره روی همان نوبت، عدم حضور دوم نمی‌سازد. */ public function testRecordingTheSameNoShowTwiceCountsOnce(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2); $first = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); $second = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user); self::assertTrue($first['data']['recorded']); self::assertFalse($second['data']['recorded']); self::assertSame(1, $second['data']['count']); } // ── جداسازی محیط ──────────────────────────────────────────────────────── public function testAnotherClinicCannotPreviewTheCancellation(): void { [$owner, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); [$other] = $this->clinicWithPatient(); $service = $this->service($section); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 5); $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $other); self::assertSame(404, $this->responseCode()); } public function testAPercentageAboveOneHundredIsRejected(): void { [$user] = $this->clinicWithPatient(); $this->authJson('PUT', '/api/v1/cancellation-policy', $user, [ 'penalty_mode' => 'percent', 'penalty_value' => 150, ]); self::assertSame(422, $this->responseCode()); } }