diff --git a/docs/api/appointment.md b/docs/api/appointment.md index eb013aa5..dabb2611 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -610,6 +610,12 @@ Confirm an appointment («ثبت شده» → «قطعی شده») and register case file — status transition, case file / visit, and payments in **one atomic transaction**. If any step fails nothing is committed. +> **پروندهٔ درمان هم اینجا باز می‌شود.** اگر سرویسِ نوبت «طول درمان» فعال داشته باشد، +> همین اندپوینت پروندهٔ چندجلسه‌ای را می‌سازد و نوبت را به اولین جلسهٔ بدون‌نوبت وصل +> می‌کند — دقیقاً مثل `PATCH /api/v1/appointment/{uuid}/status`. +> شکستِ این مرحله نوبت و پرداخت را برنمی‌گرداند؛ فقط لاگ می‌شود. پرونده‌های +> جامانده را `php bin/console app:treatment:backfill-cases --fix` می‌سازد. + **Permission:** `AUTH` — `appointments.update_status` per the [single-appointment access model](#single-appointment-access-model). diff --git a/src/Appointment/Service/AppointmentConfirmationService.php b/src/Appointment/Service/AppointmentConfirmationService.php index e700da64..f9af6582 100644 --- a/src/Appointment/Service/AppointmentConfirmationService.php +++ b/src/Appointment/Service/AppointmentConfirmationService.php @@ -136,6 +136,12 @@ class AppointmentConfirmationService ); } + // همان کاری که onConfirmed می‌کند. نبودنش یعنی نوبتی که از پنل و با + // پرداخت قطعی شده — یعنی مسیر عادیِ منشی — هرگز پروندهٔ درمان نمی‌گیرد و + // جلسه‌ای هم ساخته نمی‌شود که در پنل پرسنل دیده شود. + // starter خودش خطا را می‌گیرد و لاگ می‌کند، پس تراکنش پرداخت را نمی‌شکند. + $this->treatmentCases->onAppointmentConfirmed($appointment, $session); + return $session; }); } diff --git a/src/Treatment/Command/BackfillTreatmentCasesCommand.php b/src/Treatment/Command/BackfillTreatmentCasesCommand.php new file mode 100644 index 00000000..d5fefe75 --- /dev/null +++ b/src/Treatment/Command/BackfillTreatmentCasesCommand.php @@ -0,0 +1,150 @@ +addOption('fix', null, InputOption::VALUE_NONE, 'Open the missing cases instead of only reporting them'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $fix = (bool) $input->getOption('fix'); + + /** @var Appointment[] $candidates */ + $candidates = $this->appointments->createQueryBuilder('a') + ->where('a.status IN (:statuses)') + ->andWhere('a.isReserve = false') + ->andWhere('a.serviceItem IS NOT NULL') + ->setParameter('statuses', self::FILED_STATUSES) + ->orderBy('a.id', 'ASC') + ->getQuery() + ->getResult(); + + $rows = []; + $opened = 0; + $failed = 0; + + foreach ($candidates as $appointment) { + $service = $appointment->getServiceItem(); + + if ($service === null || $this->protocols->findActiveForService($service) === null) { + continue; + } + + $clinic = $appointment->getClinic(); + [$entityType, $entityId] = $clinic !== null + ? ['clinic', (int) $clinic->getId()] + : ['doctor', (int) $appointment->getDoctor()->getId()]; + + $session = $this->sessions->findByAppointmentAndEntity($appointment, $entityType, $entityId); + + // بدون مراجعه، پرونده جایی برای نشستن ندارد؛ آن یکی را + // app:appointment:backfill-sessions درست می‌کند، نه این. + if ($session === null) { + $rows[] = [$appointment->getId(), $service->getName(), 'no patient session', '—']; + continue; + } + + if ($this->hasTreatmentSession($appointment)) { + continue; + } + + if (!$fix) { + $rows[] = [$appointment->getId(), $service->getName(), 'no treatment case', 'would open']; + continue; + } + + try { + $case = $this->starter->onAppointmentConfirmed($appointment, $session); + $this->em->flush(); + + if ($case === null) { + ++$failed; + $rows[] = [$appointment->getId(), $service->getName(), 'starter returned null', 'see log']; + continue; + } + + ++$opened; + $rows[] = [$appointment->getId(), $service->getName(), 'case ' . $case->getUuid(), 'opened']; + } catch (\Throwable $e) { + ++$failed; + $rows[] = [$appointment->getId(), $service->getName(), $e::class . ': ' . $e->getMessage(), 'failed']; + } + } + + if ($rows === []) { + $io->success('Every confirmed appointment on a protocol service already has its treatment case.'); + + return Command::SUCCESS; + } + + $io->table(['appointment', 'service', 'detail', 'action'], $rows); + + if (!$fix) { + $io->note(sprintf('%d appointment(s) need a case. Re-run with --fix to open them.', count($rows))); + + return Command::SUCCESS; + } + + $io->writeln(sprintf('opened: %d — failed: %d', $opened, $failed)); + + return $failed > 0 ? Command::FAILURE : Command::SUCCESS; + } + + /** یک نوبت حداکثر به یک جلسهٔ درمان وصل است، پس وجودش یعنی پرونده ساخته شده. */ + private function hasTreatmentSession(Appointment $appointment): bool + { + return (int) $this->em->createQuery( + 'SELECT COUNT(s.id) FROM App\Treatment\Entity\TreatmentSession s WHERE s.appointment = :a', + )->setParameter('a', $appointment)->getSingleScalarResult() > 0; + } +} diff --git a/tests/Treatment/OpenCaseOnConfirmTest.php b/tests/Treatment/OpenCaseOnConfirmTest.php index b7393b68..f0106cf8 100644 --- a/tests/Treatment/OpenCaseOnConfirmTest.php +++ b/tests/Treatment/OpenCaseOnConfirmTest.php @@ -29,7 +29,7 @@ class OpenCaseOnConfirmTest extends ApiTestCase return static::getContainer()->get(AppointmentConfirmationService::class); } - private function scenario(bool $withProtocol, ?string $domainCode = 'beauty'): array + private function scenario(bool $withProtocol, ?string $domainCode = 'beauty', bool $confirm = true): array { $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمون'); $this->em->persist($doctor); @@ -82,7 +82,9 @@ class OpenCaseOnConfirmTest extends ApiTestCase $clinic, ); $appointment->addServiceItem($service); - $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + if ($confirm) { + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + } $this->em->persist($appointment); $this->em->flush(); @@ -111,6 +113,33 @@ class OpenCaseOnConfirmTest extends ApiTestCase )); } + /** + * مسیر واقعیِ منشی: قطعی‌کردن از پنل با مودال پرداخت. + * + * این مسیر جدا از onConfirmed است و مدتی پروندهٔ درمان را نمی‌ساخت — نوبت قطعی + * می‌شد، پول ثبت می‌شد، و پنل پرسنل خالی می‌ماند چون هیچ جلسه‌ای وجود نداشت. + */ + public function testConfirmingFromThePanelWithPaymentsOpensACase(): void + { + [$appointment, $service] = $this->scenario(withProtocol: true, confirm: false); + + $this->confirmation()->confirmWithPayments( + $appointment, + $appointment->getVersion(), + [['method' => 'cash', 'amount_rials' => 5_000_000]], + $this->createUser(['ROLE_CLINIC']), + ); + + $cases = $this->casesFor($service); + self::assertCount(1, $cases); + self::assertCount(3, $cases[0]->getSessions()); + + $sessions = $cases[0]->getSessions()->toArray(); + usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int + => $a->getSessionNumber() <=> $b->getSessionNumber()); + self::assertSame($appointment->getId(), $sessions[0]->getAppointment()?->getId()); + } + /** سرویس بدون پروتکل همان نوبت تک‌جلسه‌ای است — نباید پرونده‌ای ساخته شود. */ public function testConfirmingAPlainServiceOpensNoCase(): void {