diff --git a/src/Billing/Command/BackfillPaidSessionInvoicesCommand.php b/src/Billing/Command/BackfillPaidSessionInvoicesCommand.php new file mode 100644 index 00000000..ecd538df --- /dev/null +++ b/src/Billing/Command/BackfillPaidSessionInvoicesCommand.php @@ -0,0 +1,116 @@ +addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی ننویس'); + // محیط‌ها مستقل‌اند و جبرانِ داده معمولاً برای یک مشتری لازم می‌شود، نه همه. + $this->addOption('tenant', null, InputOption::VALUE_REQUIRED, 'فقط یک محیط، به شکل type:id — مثلاً clinic:5'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = (bool) $input->getOption('dry-run'); + + $dql = 'SELECT s FROM ' . PatientSession::class . ' s + JOIN s.record r + WHERE EXISTS (SELECT 1 FROM App\Patient\Entity\SessionPayment p WHERE IDENTITY(p.session) = s.id) + AND NOT EXISTS (SELECT 1 FROM App\Billing\Entity\Invoice i WHERE i.patientSessionId = s.id)'; + + $tenant = $this->parseTenant($input->getOption('tenant')); + if ($tenant !== null) { + $dql .= ' AND r.entityType = :type AND r.entityId = :id'; + } + + $query = $this->em->createQuery($dql); + if ($tenant !== null) { + $query->setParameter('type', $tenant[0])->setParameter('id', $tenant[1]); + } + + /** @var list $sessions */ + $sessions = $query->getResult(); + + if ($sessions === []) { + $io->success('همهٔ مراجعه‌های پرداخت‌شده صورتحساب دارند.'); + + return Command::SUCCESS; + } + + $rows = []; + foreach ($sessions as $session) { + $record = $session->getRecord(); + $rows[] = [ + $session->getId(), + $record->getEntityType() . ':' . $record->getEntityId(), + $session->getPaidTotalRials(), + ]; + + if (!$dryRun) { + $this->sessionBilling->ensureFinalizedInvoice( + $session, + $record->getEntityType(), + $record->getEntityId(), + ); + } + } + + $io->table(['session', 'tenant', 'paid_rials'], $rows); + $io->success(sprintf( + '%d مراجعه %s.', + count($rows), + $dryRun ? 'صورتحساب ندارند (dry-run)' : 'صورتحساب گرفتند', + )); + + return Command::SUCCESS; + } + + /** @return array{0: string, 1: int}|null */ + private function parseTenant(mixed $raw): ?array + { + if (!is_string($raw) || $raw === '') { + return null; + } + + [$type, $id] = array_pad(explode(':', $raw, 2), 2, null); + if ($type === null || $id === null || !ctype_digit((string) $id)) { + throw new \InvalidArgumentException('قالب tenant باید type:id باشد — مثلاً clinic:5'); + } + + return [$type, (int) $id]; + } +} diff --git a/tests/Billing/BackfillPaidSessionInvoicesTest.php b/tests/Billing/BackfillPaidSessionInvoicesTest.php new file mode 100644 index 00000000..7210ee27 --- /dev/null +++ b/tests/Billing/BackfillPaidSessionInvoicesTest.php @@ -0,0 +1,141 @@ +find('app:billing:backfill-paid-invoices')); + + $args = ['--tenant' => 'doctor:' . $doctorId]; + if ($dryRun) { + $args['--dry-run'] = true; + } + $tester->execute($args); + + return $tester->getDisplay(); + } + + /** @return array{0: PatientSession, 1: int} مراجعهٔ پرداخت‌شدهٔ بدون صورتحساب و شناسهٔ پزشکش */ + private function makePaidSessionWithoutInvoice(): array + { + $user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, 'دکتر بک‌فیل'); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + $record = new PatientRecord('doctor', $doctor->getId(), $this->createUser(), 'doctor', $doctor->getId()); + $this->em->persist($record); + + $session = new PatientSession($record); + $session->setVisitPriceRials(3_000_000); + $session->applyShares(3_000_000, 0, 0, 3_000_000); + $this->em->persist($session); + $this->em->flush(); + + // مسیر واقعی ثبت پول، ولی صورتحسابی که همان مسیر می‌سازد عمداً حذف می‌شود + // تا وضعیتِ پیش از اصلاح بازسازی شود. + static::getContainer()->get(PatientService::class) + ->addSessionPayment($session, 'cash', 1_000_000); + + foreach ($this->em->getRepository(Invoice::class)->findBy(['patientSessionId' => $session->getId()]) as $invoice) { + $this->em->remove($invoice); + } + $this->em->flush(); + + return [$session, $doctor->getId()]; + } + + public function testBackfillCreatesTheMissingFinalizedInvoice(): void + { + [$session, $doctorId] = $this->makePaidSessionWithoutInvoice(); + + $this->runCommand($doctorId); + $this->em->clear(); + + $invoices = $this->em->getRepository(Invoice::class)->findBy(['patientSessionId' => $session->getId()]); + self::assertCount(1, $invoices); + self::assertSame(Invoice::STATUS_FINALIZED, $invoices[0]->getStatus()); + self::assertSame(3_000_000, $invoices[0]->getTotalRials()); + } + + public function testDryRunWritesNothing(): void + { + [$session, $doctorId] = $this->makePaidSessionWithoutInvoice(); + + $display = $this->runCommand($doctorId, dryRun: true); + $this->em->clear(); + + self::assertStringContainsString((string) $session->getId(), $display); + self::assertSame([], $this->em->getRepository(Invoice::class)->findBy(['patientSessionId' => $session->getId()])); + } + + public function testRunningTwiceDoesNotDuplicateInvoices(): void + { + [$session, $doctorId] = $this->makePaidSessionWithoutInvoice(); + + $this->runCommand($doctorId); + $this->runCommand($doctorId); + $this->em->clear(); + + self::assertCount(1, $this->em->getRepository(Invoice::class)->findBy(['patientSessionId' => $session->getId()])); + } + + /** مرزی: مراجعهٔ بدون پرداخت نباید صورتحساب بگیرد. */ + public function testUnpaidSessionIsLeftAlone(): void + { + $user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, 'دکتر بدون پرداخت'); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + $record = new PatientRecord('doctor', $doctor->getId(), $this->createUser(), 'doctor', $doctor->getId()); + $this->em->persist($record); + + $session = new PatientSession($record); + $session->setVisitPriceRials(1_000_000); + $session->applyShares(1_000_000, 0, 0, 1_000_000); + $this->em->persist($session); + $this->em->flush(); + + $this->runCommand($doctor->getId()); + $this->em->clear(); + + self::assertSame([], $this->em->getRepository(Invoice::class)->findBy(['patientSessionId' => $session->getId()])); + } + + /** رفتار سرویس، مستقل از کامند: پرداخت‌دار یعنی صورتحساب لازم. */ + public function testServiceCreatesInvoiceOnlyWhenMoneyOrInsuranceExists(): void + { + $billing = static::getContainer()->get(SessionBillingService::class); + [$session] = $this->makePaidSessionWithoutInvoice(); + $record = $session->getRecord(); + + $invoice = $billing->ensureFinalizedInvoice($session, $record->getEntityType(), $record->getEntityId()); + + self::assertNotNull($invoice); + self::assertSame(Invoice::STATUS_FINALIZED, $invoice->getStatus()); + } +}