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, int $price = 5_000_000): ServiceItem { $item = new ServiceItem($section, $name); $item->setSoloDurationMinutes(20); $item->setPriceRials($price); $this->em->persist($item); $this->em->flush(); return $item; } /** @param array $extra */ private function definePackage(User $user, ServiceItem $service, int $sessions = 6, array $extra = []): array { $body = $this->authJson('POST', '/api/v1/packages', $user, $extra + [ 'name' => '۶ جلسه لیزر فول‌بادی', 'session_count' => $sessions, 'price_rials' => 25_000_000, 'service_uuids' => [$service->getUuid()], ]); self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); return $body['data']; } private function sell(User $user, PatientRecord $patient, string $packageUuid): array { $body = $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [ 'package_uuid' => $packageUuid, ]); self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); return $body['data']; } /** * هر درخواست HTTP کرنل را ری‌بوت می‌کند و EntityManager تازه می‌شود، پس entity های * قبلی detached اند و باید دوباره خوانده شوند. */ private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): Appointment { // یک اسلات یکتا per فراخوانی: پزشک کلید یکتای (doctor, slot_start) دارد. $start = time() + 86400 + (++$this->slotCursor) * 3600; $doctor = $this->em->getRepository(Doctor::class)->find($doctor->getId()); $patient = $this->em->getRepository(PatientRecord::class)->find($patient->getId()); $service = $this->em->getRepository(ServiceItem::class)->find($service->getId()); $appointment = new Appointment($doctor, $patient->getUser(), $start, $start + 1200); $appointment->assignTenantPair('clinic', $clinicId); $appointment->setServiceItem($service); $appointment->setPatientName('بیمار پکیج'); $this->em->persist($appointment); $this->em->flush(); return $appointment; } private function ledgerService(): CreditLedgerService { return static::getContainer()->get(CreditLedgerService::class); } /** * سرویس‌های کانتینر با EntityManager خودشان کار می‌کنند؛ entity ساخته‌شده در تست * باید از همان EM دوباره خوانده شود وگرنه «موجودیت جدیدِ persist نشده» می‌شود. */ private function reload(Appointment $appointment): Appointment { return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class) ->getRepository(Appointment::class) ->find($appointment->getId()); } private function consumption(): \App\Package\Service\PackageConsumptionService { return static::getContainer()->get(\App\Package\Service\PackageConsumptionService::class); } // ── تعریف و فروش ──────────────────────────────────────────────────────── public function testSellingAPackageOpensTheLedgerWithItsSessionCount(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر فول‌بادی'); $package = $this->definePackage($user, $service); $sold = $this->sell($user, $patient, $package['uuid']); self::assertSame(6, $sold['balance']); $list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user); self::assertSame(6, $list['data'][0]['balance']); $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); self::assertCount(1, $ledger['data']['rows']); self::assertSame('purchase', $ledger['data']['rows'][0]['kind']); self::assertSame(6, $ledger['data']['rows'][0]['delta']); self::assertSame(6, $ledger['data']['rows'][0]['running_balance']); } /** پکیجی که هیچ سرویسی را پوشش نمی‌دهد هرگز قابل مصرف نیست. */ public function testAPackageWithoutServicesIsRejected(): void { [$user] = $this->clinicWithPatient(); $this->authJson('POST', '/api/v1/packages', $user, [ 'name' => 'پکیج بی‌سرویس', 'session_count' => 3, 'price_rials' => 1_000_000, 'service_uuids' => [], ]); self::assertSame(422, $this->responseCode()); } /** ⭐ مانده باید محاسبه شود، نه ذخیره — همین جلوی «بهینه‌سازی» شش ماه بعد را می‌گیرد. */ public function testNoStoredBalanceColumnExists(): void { $columns = $this->em->getConnection() ->createSchemaManager() ->listTableColumns('patient_packages'); $names = array_map(static fn ($c): string => strtolower($c->getName()), $columns); foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) { self::assertNotContains($forbidden, $names, 'مانده باید از دفتر محاسبه شود، نه ذخیره'); } } // ── مصرف و بازگشت ─────────────────────────────────────────────────────── public function testConsumingLeavesARowAndCancellingAddsAnotherOne(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $package = $this->definePackage($user, $service); $sold = $this->sell($user, $patient, $package['uuid']); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId()); $appointment = $this->reload($appointment); self::assertTrue($this->consumption()->consumeFor($appointment)); $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); self::assertSame(5, $this->ledgerService()->balance($entity)); self::assertTrue($this->ledgerService()->refund($appointment)); self::assertSame(6, $this->ledgerService()->balance($entity)); // ردیف `consume` **حذف نمی‌شود** — دفتر append-only است. $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); $kinds = array_column($ledger['data']['rows'], 'kind'); self::assertSame(['purchase', 'consume', 'refund'], $kinds); self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance')); } /** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */ public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); $appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId()); $appointment = $this->reload($appointment); $this->consumption()->consumeFor($appointment); $this->consumption()->consumeFor($appointment); $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); self::assertSame(5, $this->ledgerService()->balance($entity)); } /** ماندهٔ صفر خطا نیست: بیمار نقدی می‌پردازد. */ public function testAnEmptyPackageIsSimplyNotApplied(): void { [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $sold = $this->sell($user, $patient, $this->definePackage($user, $service, 1)['uuid']); $first = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId())); $second = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId())); self::assertTrue($this->consumption()->consumeFor($first)); self::assertFalse($this->consumption()->consumeFor($second), 'ماندهٔ صفر باید بی‌سروصدا رد شود'); $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); self::assertSame(0, $this->ledgerService()->balance($entity), 'مانده هرگز منفی نمی‌شود'); } // ── قیمت ──────────────────────────────────────────────────────────────── public function testQuoteAnnouncesThePackageWithoutConsumingIt(): void { [$user, $section, $address, , $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر', 5_000_000); $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); $quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [ 'service_uuid' => $service->getUuid(), 'branch_uuid' => $address->getUuid(), 'patient_uuid' => $patient->getUuid(), ]); self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE)); self::assertTrue($quote['data']['package_will_be_consumed']); self::assertSame(0, $quote['data']['final_rials']); // پیش‌نمایش هرگز مصرف نمی‌کند؛ وگرنه هر رفرش یک جلسه می‌خورد. $this->authJson('POST', '/api/v1/pricing/quote', $user, [ 'service_uuid' => $service->getUuid(), 'branch_uuid' => $address->getUuid(), 'patient_uuid' => $patient->getUuid(), ]); $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); self::assertSame(6, $this->ledgerService()->balance($entity)); } public function testQuoteWithoutAPatientChargesTheFullPrice(): void { [$user, $section, $address, , $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر', 5_000_000); $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); $quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [ 'service_uuid' => $service->getUuid(), 'branch_uuid' => $address->getUuid(), ]); self::assertFalse($quote['data']['package_will_be_consumed']); self::assertSame(5_000_000, $quote['data']['final_rials']); } // ── FIFO و انقضا ──────────────────────────────────────────────────────── /** قدیمی‌ترین اول، چون به انقضا نزدیک‌تر است. */ public function testTheOldestUnexpiredPackageIsUsedFirst(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $definition = $this->definePackage($user, $service); $older = $this->sell($user, $patient, $definition['uuid']); $newer = $this->sell($user, $patient, $definition['uuid']); $repo = static::getContainer()->get(PatientPackageRepository::class); $olderE = $repo->findByUuid($older['uuid']); // خرید دوم را عمداً تازه‌تر می‌کنیم تا ترتیب قطعی باشد. $this->em->getConnection()->executeStatement( 'UPDATE patient_packages SET purchased_at = purchased_at + 100 WHERE uuid = ?', [$newer['uuid']], ); $this->em->clear(); $chosen = $this->consumption()->firstUsable( $this->em->getRepository(PatientRecord::class)->find($patient->getId()), $this->em->getRepository(ServiceItem::class)->find($service->getId()), ); self::assertSame($olderE->getUuid(), $chosen?->getUuid()); } public function testAnExpiredPackageShowsZeroBalanceButKeepsItsLedger(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); $this->em->getConnection()->executeStatement( 'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?', [time() - 86400, $sold['uuid']], ); $this->em->clear(); $list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user); self::assertTrue($list['data'][0]['expired']); self::assertSame(0, $list['data'][0]['balance']); // دفتر دست‌نخورده است: «۶ جلسه‌ام چه شد؟» هنوز جواب دارد. $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); self::assertSame(6, $ledger['data']['rows'][0]['running_balance']); } public function testExpiryCommandWritesTheClosingRow(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); $this->em->getConnection()->executeStatement( 'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?', [time() - 86400, $sold['uuid']], ); $this->em->clear(); $command = static::getContainer()->get(\App\Package\Command\ExpirePackagesCommand::class); $tester = new \Symfony\Component\Console\Tester\CommandTester($command); $tester->execute([]); $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); $kinds = array_column($ledger['data']['rows'], 'kind'); self::assertSame(['purchase', 'expiry'], $kinds); self::assertSame(-6, $ledger['data']['rows'][1]['delta']); self::assertSame(0, $ledger['data']['rows'][1]['running_balance']); } // ── اصلاح دستی و جداسازی محیط ─────────────────────────────────────────── public function testAdjustmentNeedsAReasonAndIsRecorded(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, ['delta' => 1]); self::assertSame(422, $this->responseCode(), 'اصلاح بدون دلیل نباید پذیرفته شود'); $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [ 'delta' => 2, 'reason' => 'جبران جلسهٔ لغوشده توسط کلینیک', ]); self::assertSame(201, $this->responseCode()); $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); $row = $ledger['data']['rows'][1]; self::assertSame('adjustment', $row['kind']); self::assertSame(2, $row['delta']); self::assertSame('جبران جلسهٔ لغوشده توسط کلینیک', $row['reason']); self::assertNotNull($row['created_by']); self::assertSame(8, $row['running_balance']); } public function testAdjustmentCannotDriveTheBalanceNegative(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [ 'delta' => -10, 'reason' => 'اشتباه اپراتور', ]); self::assertSame(422, $this->responseCode()); } public function testAnotherClinicCannotSeeOrTouchThePackage(): void { [$owner, $section, , , $patient] = $this->clinicWithPatient(); [$other] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']); $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $other); self::assertSame(404, $this->responseCode()); $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $other, [ 'delta' => 5, 'reason' => 'تلاش از محیط دیگر', ]); self::assertSame(404, $this->responseCode()); } /** منشی نباید بتواند اعتبار را دستی عوض کند. */ public function testASecretaryCannotAdjustTheLedger(): void { [$owner, $section, , , $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']); $secretary = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']); $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $secretary, [ 'delta' => 5, 'reason' => 'تلاش منشی', ]); self::assertContains($this->responseCode(), [403, 404]); } public function testLedgerRowsNeverHaveAZeroDelta(): void { [$user, $section, , , $patient] = $this->clinicWithPatient(); $service = $this->service($section, 'لیزر'); $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); $this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [ 'delta' => 0, 'reason' => 'بی‌اثر', ]); self::assertSame(422, $this->responseCode()); $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); self::expectException(\InvalidArgumentException::class); new SessionCreditLedger($entity, SessionCreditLedger::KIND_ADJUSTMENT, 0); } }