diff --git a/docs/api/practice-domain.md b/docs/api/practice-domain.md index fe268b01..bc4a569c 100644 --- a/docs/api/practice-domain.md +++ b/docs/api/practice-domain.md @@ -31,12 +31,18 @@ be able to switch them back on. "code": "beauty", "name": "کلینیک زیبایی", "sort_order": 0, - "active": true + "active": true, + "has_workflow": true } ] } ``` +`has_workflow` می‌گوید آیا پیاده‌سازیِ `TreatmentWorkflow` مخصوصِ این کد ثبت شده یا حوزه +فقط رفتار پیش‌فرض می‌گیرد. حوزهٔ بی‌workflow کار می‌کند — دوره‌های چندجلسه‌ای‌اش باز +می‌شوند — ولی رفتار اختصاصی ندارد، و پنل ادمین پلتفرم باید همین را نشان دهد نه اینکه +مدیر حدس بزند. + **Errors:** | Code | HTTP | توضیح | |------|------|-------| diff --git a/docs/api/treatment.md b/docs/api/treatment.md index 06638f9b..1918e303 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -130,6 +130,49 @@ Real 422 responses: --- +## Opening a case — what happens on confirm + +There is no endpoint that opens a treatment case; it happens as a side effect of confirming an +appointment, in `AppointmentConfirmationService::onConfirmed`: + +``` +نوبت تأیید شد + → PatientSession ساخته می‌شود (مالی، مثل همیشه) + → اگر سرویسِ نوبت پروتکل فعال دارد: + TreatmentWorkflowRegistry::for(clinic.practice_domain.code)->openCase(...) +``` + +The booking core never names a specialty. A `TreatmentWorkflow` is selected by the clinic's practice +domain code through a tagged-service registry, so adding dentistry is a new class rather than a +change in the booking path. `LaserTreatmentWorkflow` handles `beauty`; +`DefaultTreatmentWorkflow` answers for everything else, including a clinic that has chosen no domain +at all — `null` means "behave as today", never an error. + +What opening a case does: + +| | | +|---|---| +| پروندهٔ باز موجود | برگردانده می‌شود؛ پروندهٔ دوم برای همان بیمار و همان سرویس ساخته نمی‌شود | +| نواحی | برگ‌های دستهٔ سرویس، با نامشان، در همان لحظه کپی می‌شوند | +| جلسات | همهٔ گام‌های پروتکل ساخته می‌شوند، همه `planned` | +| نوبت | به **اولین جلسهٔ بدون نوبت** می‌چسبد و آن جلسه `booked` می‌شود | +| سررسید | جلسهٔ رزروشده ساعت نوبت را می‌گیرد؛ بقیه `null` می‌مانند | + +Failure to open a case is logged and swallowed — the appointment is booked and possibly paid for, and +losing that is worse than losing the case file, which can be rebuilt. + +### Session due dates + +`due_at` of session *n* is `finished_at` of session *n−1* plus that step's `offset_days`. Only the +**next** session is recomputed when one finishes; sessions further out keep their earlier estimate, +because a number that is not yet anchored to anything real does not get more accurate by being +recalculated. + +A no-show does not burn the session: its status becomes `no_show`, its appointment link is cleared, +`total_sessions` is untouched, and the same session comes back to the front of the booking queue. + +--- + ## DELETE `/api/v1/service-item/{uuid}/treatment-protocol` Turn the switch off — the protocol, its steps and its staff list are removed and the service is diff --git a/src/Appointment/Service/AppointmentConfirmationService.php b/src/Appointment/Service/AppointmentConfirmationService.php index 9d4cf9df..e700da64 100644 --- a/src/Appointment/Service/AppointmentConfirmationService.php +++ b/src/Appointment/Service/AppointmentConfirmationService.php @@ -30,6 +30,7 @@ class AppointmentConfirmationService private readonly PaymentRepository $paymentRepo, private readonly CommissionService $commissionService, private readonly DomainContextResolver $domainResolver, + private readonly \App\Treatment\Service\TreatmentCaseStarter $treatmentCases, private readonly EntityManagerInterface $em, private readonly LoggerInterface $logger, ) {} @@ -72,7 +73,15 @@ class AppointmentConfirmationService $this->splitPaymentShares($appointment); try { - return $this->patientService->autoCreateOnAppointmentConfirm($appointment); + $session = $this->patientService->autoCreateOnAppointmentConfirm($appointment); + + // هسته نمی‌داند دورهٔ درمان چیست: اگر سرویسِ نوبت پروتکل داشته باشد، + // workflowِ حوزهٔ فعالیت تصمیم می‌گیرد چه اتفاقی بیفتد. + if ($session !== null) { + $this->treatmentCases->onAppointmentConfirmed($appointment, $session); + } + + return $session; } catch (\Throwable $e) { $this->logger->error('Auto-creating the patient record on confirm failed', [ 'appointment_uuid' => $appointment->getUuid(), diff --git a/src/PracticeDomain/Controller/PracticeDomainController.php b/src/PracticeDomain/Controller/PracticeDomainController.php index 07ac255a..5c29f755 100644 --- a/src/PracticeDomain/Controller/PracticeDomainController.php +++ b/src/PracticeDomain/Controller/PracticeDomainController.php @@ -5,6 +5,7 @@ namespace App\PracticeDomain\Controller; use App\PracticeDomain\Entity\PracticeDomain; use App\PracticeDomain\Repository\PracticeDomainRepository; use App\Shared\Constant\ErrorCodes; +use App\Treatment\Workflow\TreatmentWorkflowRegistry; use App\Shared\Controller\BaseController; use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; @@ -19,6 +20,7 @@ class PracticeDomainController extends BaseController { public function __construct( private readonly PracticeDomainRepository $domains, + private readonly TreatmentWorkflowRegistry $workflows, private readonly EntityManagerInterface $em, ) {} @@ -34,7 +36,7 @@ class PracticeDomainController extends BaseController $includeInactive = $this->isGranted('ROLE_ADMIN'); return $this->success(array_map( - static fn (PracticeDomain $d): array => $d->toArray(), + fn (PracticeDomain $d): array => $d->toArray($this->workflows->hasDedicatedWorkflow($d->getCode())), $this->domains->findOrdered($includeInactive), )); } @@ -73,7 +75,7 @@ class PracticeDomainController extends BaseController $this->em->persist($domain); $this->em->flush(); - return $this->success($domain->toArray(), 201); + return $this->success($domain->toArray($this->workflows->hasDedicatedWorkflow($domain->getCode())), 201); } #[Route('/api/v1/practice-domain/{uuid}', name: 'practice_domain_update', methods: ['PATCH'])] @@ -108,6 +110,6 @@ class PracticeDomainController extends BaseController $this->em->flush(); - return $this->success($domain->toArray()); + return $this->success($domain->toArray($this->workflows->hasDedicatedWorkflow($domain->getCode()))); } } diff --git a/src/Treatment/Repository/TreatmentSessionRepository.php b/src/Treatment/Repository/TreatmentSessionRepository.php index 51d6c5c4..bb8b4488 100644 --- a/src/Treatment/Repository/TreatmentSessionRepository.php +++ b/src/Treatment/Repository/TreatmentSessionRepository.php @@ -47,6 +47,27 @@ class TreatmentSessionRepository extends ServiceEntityRepository ->getOneOrNullResult(); } + /** + * اولین جلسه‌ای که هنوز نوبتی ندارد. + * + * جدا از {@see self::findNextOpen()} است چون آن جلسهٔ رزروشده را هم «باز» می‌داند: + * بیماری که وسط دوره نوبت دوم می‌گیرد باید به جلسهٔ **بعدی** بچسبد، نه به جلسه‌ای + * که همین حالا نوبت دارد. + */ + public function findNextUnbooked(TreatmentCase $case): ?TreatmentSession + { + return $this->createQueryBuilder('s') + ->where('s.treatmentCase = :case') + ->andWhere('s.appointment IS NULL') + ->andWhere('s.status IN (:open)') + ->setParameter('case', $case) + ->setParameter('open', [TreatmentSession::STATUS_PLANNED, TreatmentSession::STATUS_NO_SHOW]) + ->orderBy('s.sessionNumber', 'ASC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + /** جلسهٔ انجام‌شدهٔ قبلی — لنگرِ محاسبهٔ سررسید جلسهٔ بعد. */ public function findLastFinishedBefore(TreatmentCase $case, int $sessionNumber): ?TreatmentSession { diff --git a/src/Treatment/Service/TreatmentCaseStarter.php b/src/Treatment/Service/TreatmentCaseStarter.php new file mode 100644 index 00000000..59c89fe1 --- /dev/null +++ b/src/Treatment/Service/TreatmentCaseStarter.php @@ -0,0 +1,64 @@ +getServiceItem(); + + if ($service === null) { + return null; + } + + $protocol = $this->protocols->findActiveForService($service); + + if ($protocol === null) { + return null; + } + + $domainCode = $appointment->getClinic()?->getPracticeDomain()?->getCode(); + + try { + return $this->workflows->for($domainCode)->openCase( + $appointment, + $session->getRecord(), + $service, + $protocol, + ); + } catch (\Throwable $e) { + // همان قاعدهٔ AppointmentConfirmationService: نوبت رزرو شده و پول پرداخت + // شده؛ شکستِ ساخت پرونده نباید آن را برگرداند. + $this->logger->error('Opening the treatment case on confirm failed', [ + 'appointment_uuid' => $appointment->getUuid(), + 'exception' => $e, + ]); + + return null; + } + } +} diff --git a/src/Treatment/Service/TreatmentScheduler.php b/src/Treatment/Service/TreatmentScheduler.php new file mode 100644 index 00000000..0b2625f3 --- /dev/null +++ b/src/Treatment/Service/TreatmentScheduler.php @@ -0,0 +1,78 @@ +getFinishedAt(); + + if ($anchor === null) { + return null; + } + + $next = $this->sessions->findNextOpen($finished->getTreatmentCase()); + + if ($next === null || $next->getSessionNumber() <= $finished->getSessionNumber()) { + return null; + } + + $offset = $this->offsetFor($finished->getTreatmentCase(), $next->getSessionNumber()); + + if ($offset === null) { + return null; + } + + $next->setDueAt($anchor + $offset * self::DAY); + + return $next; + } + + /** + * سررسید جلسه‌ای که هنوز لنگری ندارد. + * + * برای جلسهٔ اول لنگر خودِ نوبت است؛ برای بقیه تا انجام‌شدن جلسهٔ قبل حدس می‌ماند. + */ + public function seedFirstSession(TreatmentSession $first, int $startsAt): void + { + $first->setDueAt($startsAt); + } + + /** فاصلهٔ گامِ شمارهٔ n از جلسهٔ قبلی، طبق پروتکلِ همان پرونده. */ + private function offsetFor(TreatmentCase $case, int $sessionNumber): ?int + { + foreach ($case->getProtocol()->getSteps() as $step) { + /** @var TreatmentProtocolStep $step */ + if ($step->getStepNumber() === $sessionNumber) { + return $step->getOffsetDays(); + } + } + + return null; + } +} diff --git a/src/Treatment/Workflow/DefaultTreatmentWorkflow.php b/src/Treatment/Workflow/DefaultTreatmentWorkflow.php new file mode 100644 index 00000000..c025f012 --- /dev/null +++ b/src/Treatment/Workflow/DefaultTreatmentWorkflow.php @@ -0,0 +1,97 @@ +cases->findOpenFor($record, $service) + ?? $this->opener->open( + $record->getEntityType(), + $record->getEntityId(), + $record, + $service, + $protocol, + ); + + $this->attachToNextOpenSession($case, $appointment); + + return $case; + } + + public function onSessionFinished(TreatmentSession $session): void + { + $next = $this->scheduler->scheduleNextAfter($session); + + if ($next === null) { + $this->closeIfEverySessionIsSettled($session->getTreatmentCase()); + } + + $this->em->flush(); + } + + /** نوبت به اولین جلسهٔ بدونِ نوبت وصل می‌شود، نه لزوماً به جلسهٔ اول. */ + protected function attachToNextOpenSession(TreatmentCase $case, Appointment $appointment): void + { + $session = $this->sessions->findNextUnbooked($case); + + if ($session === null) { + return; + } + + $session->attachAppointment($appointment); + // سررسیدِ جلسه‌ای که نوبت گرفته دیگر حدس نیست — همان ساعت نوبت است. + $this->scheduler->seedFirstSession($session, $appointment->getSlotStart()); + $this->em->flush(); + } + + protected function closeIfEverySessionIsSettled(TreatmentCase $case): void + { + foreach ($case->getSessions() as $session) { + if (!in_array($session->getStatus(), [TreatmentSession::STATUS_DONE, TreatmentSession::STATUS_CANCELLED], true)) { + return; + } + } + + $case->close(TreatmentCase::STATUS_COMPLETED); + } +} diff --git a/src/Treatment/Workflow/LaserTreatmentWorkflow.php b/src/Treatment/Workflow/LaserTreatmentWorkflow.php new file mode 100644 index 00000000..953db67e --- /dev/null +++ b/src/Treatment/Workflow/LaserTreatmentWorkflow.php @@ -0,0 +1,26 @@ + $workflows */ + public function __construct( + #[AutowireIterator('app.treatment_workflow')] + private readonly iterable $workflows, + ) {} + + public function for(?string $practiceDomainCode): TreatmentWorkflow + { + foreach ($this->workflows as $workflow) { + if ($workflow->supports($practiceDomainCode)) { + return $workflow; + } + } + + throw new \LogicException(sprintf( + 'No treatment workflow supports "%s"; DefaultTreatmentWorkflow should have caught it.', + $practiceDomainCode ?? 'null', + )); + } + + /** + * آیا این حوزه پیاده‌سازی اختصاصی دارد، یا فقط رفتار پیش‌فرض می‌گیرد؟ + * + * پنل ادمین پلتفرم با همین کنار هر حوزه نشان می‌دهد که هنوز workflow ندارد — حوزهٔ + * بی‌workflow کار می‌کند ولی رفتار اختصاصی ندارد، و این باید دیده شود نه حدس زده. + */ + public function hasDedicatedWorkflow(?string $practiceDomainCode): bool + { + return $practiceDomainCode !== null + && $this->for($practiceDomainCode)::class !== DefaultTreatmentWorkflow::class; + } +} diff --git a/tests/Treatment/OpenCaseOnConfirmTest.php b/tests/Treatment/OpenCaseOnConfirmTest.php new file mode 100644 index 00000000..b7393b68 --- /dev/null +++ b/tests/Treatment/OpenCaseOnConfirmTest.php @@ -0,0 +1,183 @@ +get(AppointmentConfirmationService::class); + } + + private function scenario(bool $withProtocol, ?string $domainCode = 'beauty'): array + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمون'); + $this->em->persist($doctor); + + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک دورهٔ درمان'); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + if ($domainCode !== null) { + $domain = $this->em->getRepository(PracticeDomain::class)->findOneBy(['code' => $domainCode]) + ?? new PracticeDomain($domainCode, 'حوزهٔ آزمون'); + $this->em->persist($domain); + $this->em->flush(); + $clinic->setPracticeDomain($domain); + } + + $section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $category = new CatalogCategory('clinic', (int) $clinic->getId(), 'دست'); + $this->em->persist($category); + + $service = new ServiceItem($section, 'لیزر دست', 5_000_000); + $service->setCatalogCategory($category); + $this->em->persist($service); + $this->em->flush(); + + if ($withProtocol) { + $staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور'); + $this->em->persist($staff); + + $protocol = new TreatmentProtocol($service); + $this->em->persist($protocol); + $protocol->replaceSteps([ + new TreatmentProtocolStep($protocol, 1, 0), + new TreatmentProtocolStep($protocol, 2, 15), + new TreatmentProtocolStep($protocol, 3, 30), + ]); + $protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]); + $this->em->flush(); + } + + $appointment = $this->newAppointment( + $doctor, + $this->createUser(['ROLE_USER']), + 1_790_000_000, + 1_790_001_800, + $clinic, + ); + $appointment->addServiceItem($service); + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appointment); + $this->em->flush(); + + return [$appointment, $service, $clinic]; + } + + /** @return TreatmentCase[] */ + private function casesFor(ServiceItem $service): array + { + return $this->em->getRepository(TreatmentCase::class)->findBy(['serviceItem' => $service]); + } + + public function testConfirmingAProtocolServiceOpensACase(): void + { + [$appointment, $service] = $this->scenario(withProtocol: true); + + $this->confirmation()->onConfirmed($appointment); + + $cases = $this->casesFor($service); + self::assertCount(1, $cases); + self::assertSame(3, $cases[0]->getTotalSessions()); + self::assertCount(3, $cases[0]->getSessions()); + self::assertSame(['دست'], array_map( + static fn ($a) => $a->getName(), + $cases[0]->getAreas()->toArray(), + )); + } + + /** سرویس بدون پروتکل همان نوبت تک‌جلسه‌ای است — نباید پرونده‌ای ساخته شود. */ + public function testConfirmingAPlainServiceOpensNoCase(): void + { + [$appointment, $service] = $this->scenario(withProtocol: false); + + $this->confirmation()->onConfirmed($appointment); + + self::assertSame([], $this->casesFor($service)); + } + + /** کلینیکِ بدون حوزهٔ فعالیت هم باید دوره‌اش کار کند — نال یعنی رفتار پیش‌فرض. */ + public function testCaseOpensEvenWithoutAPracticeDomain(): void + { + [$appointment, $service] = $this->scenario(withProtocol: true, domainCode: null); + + $this->confirmation()->onConfirmed($appointment); + + self::assertCount(1, $this->casesFor($service)); + } + + public function testFirstSessionIsAttachedToTheAppointment(): void + { + [$appointment, $service] = $this->scenario(withProtocol: true); + + $this->confirmation()->onConfirmed($appointment); + + $sessions = $this->casesFor($service)[0]->getSessions()->toArray(); + usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int + => $a->getSessionNumber() <=> $b->getSessionNumber()); + + self::assertSame($appointment->getId(), $sessions[0]->getAppointment()?->getId()); + self::assertSame(TreatmentSession::STATUS_BOOKED, $sessions[0]->getStatus()); + self::assertSame($appointment->getSlotStart(), $sessions[0]->getDueAt()); + + // جلسات بعدی نه نوبت دارند نه سررسید قطعی — سررسیدشان از جلسهٔ قبل می‌آید. + self::assertNull($sessions[1]->getAppointment()); + self::assertNull($sessions[1]->getDueAt()); + } + + /** بیمار وسط دوره نوبت دیگری می‌گیرد — باید جلسهٔ همان دوره باشد نه دورهٔ موازی. */ + public function testASecondAppointmentDoesNotOpenASecondCase(): void + { + [$appointment, $service, $clinic] = $this->scenario(withProtocol: true); + + $this->confirmation()->onConfirmed($appointment); + + $second = $this->newAppointment( + $appointment->getDoctor(), + $appointment->getUser(), + 1_791_000_000, + 1_791_001_800, + $clinic, + ); + $second->addServiceItem($service); + $second->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($second); + $this->em->flush(); + + $this->confirmation()->onConfirmed($second); + + $cases = $this->casesFor($service); + self::assertCount(1, $cases); + + $sessions = $cases[0]->getSessions()->toArray(); + usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int + => $a->getSessionNumber() <=> $b->getSessionNumber()); + + self::assertSame($second->getId(), $sessions[1]->getAppointment()?->getId()); + } +} diff --git a/tests/Treatment/TreatmentSchedulerTest.php b/tests/Treatment/TreatmentSchedulerTest.php new file mode 100644 index 00000000..9d67c769 --- /dev/null +++ b/tests/Treatment/TreatmentSchedulerTest.php @@ -0,0 +1,198 @@ +sessions = $this->em->getRepository(TreatmentSession::class); + $this->scheduler = new TreatmentScheduler($this->sessions); + } + + /** سناریوی بوتاکس: جلسه ۲ بعد از ۱۵ روز، بقیه ماهانه. */ + private function botoxCase(): TreatmentCase + { + $user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک بوتاکس'); + $this->em->persist($clinic); + $this->em->flush(); + + $section = new ServiceSection('clinic', (int) $clinic->getId(), 'تزریق'); + $this->em->persist($section); + + $category = new CatalogCategory('clinic', (int) $clinic->getId(), 'پیشانی'); + $this->em->persist($category); + + $service = new ServiceItem($section, 'بوتاکس پیشانی', 8_000_000); + $service->setCatalogCategory($category); + $this->em->persist($service); + + $staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور'); + $this->em->persist($staff); + + $protocol = new TreatmentProtocol($service); + $this->em->persist($protocol); + $protocol->replaceSteps([ + new TreatmentProtocolStep($protocol, 1, 0), + new TreatmentProtocolStep($protocol, 2, 15), + new TreatmentProtocolStep($protocol, 3, 30), + new TreatmentProtocolStep($protocol, 4, 30), + ]); + $protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]); + $this->em->flush(); + + $record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId()); + $this->em->persist($record); + $this->em->flush(); + + $opener = new TreatmentCaseOpener( + new CategoryClosureResolver($this->em->getRepository(CatalogCategoryInclude::class)), + $this->em->getRepository(CatalogCategory::class), + $this->em, + ); + + return $opener->open('clinic', (int) $clinic->getId(), $record, $service, $protocol); + } + + /** @return TreatmentSession[] */ + private function orderedSessions(TreatmentCase $case): array + { + $sessions = $case->getSessions()->toArray(); + usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int + => $a->getSessionNumber() <=> $b->getSessionNumber()); + + return $sessions; + } + + private function finish(TreatmentSession $session, int $at): void + { + $session->finish(); + // finish() زمان واقعی می‌گذارد؛ تست تاریخ را کنترل‌شده جا می‌اندازد. + $ref = new \ReflectionProperty(TreatmentSession::class, 'finishedAt'); + $ref->setValue($session, $at); + $this->em->flush(); + } + + public function testUnevenIntervalsAreMeasuredFromTheLastFinishedSession(): void + { + $case = $this->botoxCase(); + $sessions = $this->orderedSessions($case); + $day1 = 1_800_000_000; + + $this->finish($sessions[0], $day1); + $next = $this->scheduler->scheduleNextAfter($sessions[0]); + self::assertSame($sessions[1]->getId(), $next?->getId()); + self::assertSame($day1 + 15 * self::DAY, $sessions[1]->getDueAt()); + + // بیمار ۲۰ روز دیر آمد — جلسهٔ سوم باید از همین تاریخ حساب شود، نه از شروع دوره. + $actual = $sessions[1]->getDueAt() + 20 * self::DAY; + $this->finish($sessions[1], $actual); + $this->scheduler->scheduleNextAfter($sessions[1]); + + self::assertSame($actual + 30 * self::DAY, $sessions[2]->getDueAt()); + } + + /** با لنگر ثابت، بیمارِ دیرآمده جلسهٔ بعدی‌اش را زودتر از موعد می‌گرفت. */ + public function testALateSessionShiftsTheRestOfTheCourse(): void + { + $case = $this->botoxCase(); + $sessions = $this->orderedSessions($case); + $day1 = 1_800_000_000; + + $this->finish($sessions[0], $day1); + $this->scheduler->scheduleNextAfter($sessions[0]); + + $late = $day1 + 60 * self::DAY; + $this->finish($sessions[1], $late); + $this->scheduler->scheduleNextAfter($sessions[1]); + + self::assertSame($late + 30 * self::DAY, $sessions[2]->getDueAt()); + self::assertGreaterThan($day1 + 45 * self::DAY, $sessions[2]->getDueAt()); + } + + /** فقط جلسهٔ بعدی بازمحاسبه می‌شود؛ جلسات دورتر هنوز لنگری ندارند. */ + public function testOnlyTheNextSessionIsRescheduled(): void + { + $case = $this->botoxCase(); + $sessions = $this->orderedSessions($case); + + $this->finish($sessions[0], 1_800_000_000); + $this->scheduler->scheduleNextAfter($sessions[0]); + + self::assertNotNull($sessions[1]->getDueAt()); + self::assertNull($sessions[2]->getDueAt()); + self::assertNull($sessions[3]->getDueAt()); + } + + /** غیبت جلسه را نمی‌سوزاند: تعداد جلسات ثابت می‌ماند و همان جلسه دوباره برنامه‌ریزی می‌شود. */ + public function testNoShowKeepsTheSessionAndTheTotal(): void + { + $case = $this->botoxCase(); + $sessions = $this->orderedSessions($case); + + $this->finish($sessions[0], 1_800_000_000); + $this->scheduler->scheduleNextAfter($sessions[0]); + + $sessions[1]->markNoShow(); + $this->em->flush(); + + self::assertSame(4, $case->getTotalSessions()); + self::assertCount(4, $case->getSessions()); + self::assertSame(TreatmentSession::STATUS_NO_SHOW, $sessions[1]->getStatus()); + + // همان جلسه دوباره در صف رزرو می‌آید، نه جلسه‌ای تازه. + self::assertSame($sessions[1]->getId(), $this->sessions->findNextUnbooked($case)?->getId()); + } + + public function testLastSessionSchedulesNothing(): void + { + $case = $this->botoxCase(); + $sessions = $this->orderedSessions($case); + + foreach ($sessions as $index => $session) { + $this->finish($session, 1_800_000_000 + $index * 30 * self::DAY); + } + + self::assertNull($this->scheduler->scheduleNextAfter($sessions[3])); + } + + public function testUnfinishedSessionSchedulesNothing(): void + { + $case = $this->botoxCase(); + $sessions = $this->orderedSessions($case); + + self::assertNull($this->scheduler->scheduleNextAfter($sessions[0])); + self::assertNull($sessions[1]->getDueAt()); + } +} diff --git a/tests/Treatment/TreatmentWorkflowTest.php b/tests/Treatment/TreatmentWorkflowTest.php new file mode 100644 index 00000000..1a7079e7 --- /dev/null +++ b/tests/Treatment/TreatmentWorkflowTest.php @@ -0,0 +1,69 @@ +registry = static::getContainer()->get(TreatmentWorkflowRegistry::class); + } + + public function testBeautyResolvesToTheLaserWorkflow(): void + { + self::assertInstanceOf(LaserTreatmentWorkflow::class, $this->registry->for('beauty')); + } + + /** حوزه‌ای که هنوز پیاده‌سازی ندارد باید رفتار پیش‌فرض بگیرد، نه خطا. */ + public function testUnknownDomainFallsBackToTheDefaultWorkflow(): void + { + self::assertSame( + DefaultTreatmentWorkflow::class, + $this->registry->for('physiotherapy')::class, + ); + } + + /** نال یعنی «حوزه تنظیم نشده» و همان رفتار امروز، نه خطا. */ + public function testNullDomainFallsBackToTheDefaultWorkflow(): void + { + self::assertSame(DefaultTreatmentWorkflow::class, $this->registry->for(null)::class); + } + + public function testHasDedicatedWorkflowDistinguishesTheTwo(): void + { + self::assertTrue($this->registry->hasDedicatedWorkflow('beauty')); + self::assertFalse($this->registry->hasDedicatedWorkflow('physiotherapy')); + self::assertFalse($this->registry->hasDedicatedWorkflow(null)); + } + + /** پنل ادمین باید ببیند کدام حوزه هنوز workflow ندارد. */ + public function testListExposesHasWorkflow(): void + { + $suffix = bin2hex(random_bytes(4)); + + foreach ([['beauty_' . $suffix, false], ['beauty', true]] as [$code, $expected]) { + if ($this->em->getRepository(PracticeDomain::class)->findOneBy(['code' => $code]) === null) { + $this->em->persist(new PracticeDomain($code, 'حوزهٔ آزمون')); + } + } + $this->em->flush(); + + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + $body = $this->authJson('GET', '/api/v1/practice-domains', $admin); + + $byCode = array_column($body['data'], 'has_workflow', 'code'); + + self::assertTrue($byCode['beauty']); + self::assertFalse($byCode['beauty_' . $suffix]); + } +}