diff --git a/src/Treatment/Repository/TreatmentSessionRepository.php b/src/Treatment/Repository/TreatmentSessionRepository.php index bb8b4488..382306ab 100644 --- a/src/Treatment/Repository/TreatmentSessionRepository.php +++ b/src/Treatment/Repository/TreatmentSessionRepository.php @@ -88,16 +88,53 @@ class TreatmentSessionRepository extends ServiceEntityRepository /** * جلسات امروزِ یک پرسنل — از روی نوبتِ متصل، نه از روی سررسید تخمینی. * + * صف است، نه فهرستِ اختصاص‌یافته. سه چیز اینجا می‌آید: + * - جلسه‌ای که همین پرسنل برداشته (`performedBy`)، + * - نوبتی که منشی از قبل به او داده (`appointment.staff`)، + * - و جلسهٔ بی‌صاحبی که پروتکلش او را مجاز می‌داند. + * + * فیلترِ قبلی فقط شرط دوم را داشت، و چون هیچ مسیرِ نوبت‌دهی اپراتور را ست + * نمی‌کند، پنل پرسنل همیشه خالی بود. + * + * پروتکلِ بدون فهرست پرسنل یعنی «همه مجازند» — همان قاعدهٔ + * `ResourceServiceOffering` که نبودِ رکورد را محدودیت حساب نمی‌کند. + * * @return TreatmentSession[] */ public function findTodayForStaff(ClinicStaff $staff, int $dayStart, int $dayEnd): array { + $allowsThisStaff = <<<'DQL' + EXISTS ( + SELECT 1 FROM App\Treatment\Entity\TreatmentProtocolStaff ps + JOIN ps.protocol p + WHERE p.serviceItem = c.serviceItem AND p.active = true AND ps.staff = :staff + ) + DQL; + + $namesAnyStaff = <<<'DQL' + EXISTS ( + SELECT 1 FROM App\Treatment\Entity\TreatmentProtocolStaff ps2 + JOIN ps2.protocol p2 + WHERE p2.serviceItem = c.serviceItem AND p2.active = true + ) + DQL; + return $this->createQueryBuilder('s') ->join('s.appointment', 'a') - ->where('a.staff = :staff') - ->andWhere('a.slotStart >= :from') + ->join('s.treatmentCase', 'c') + ->where('a.slotStart >= :from') ->andWhere('a.slotStart <= :to') + // محیط، وگرنه پرسنلِ یک کلینیک جلسات کلینیک دیگر را می‌بیند. + ->andWhere('s.entityType = :type') + ->andWhere('s.entityId = :id') + ->andWhere(sprintf( + 's.performedBy = :staff OR a.staff = :staff OR (s.performedBy IS NULL AND a.staff IS NULL AND (%s OR NOT %s))', + $allowsThisStaff, + $namesAnyStaff, + )) ->setParameter('staff', $staff) + ->setParameter('type', $staff->getEntityType()) + ->setParameter('id', $staff->getEntityId()) ->setParameter('from', $dayStart) ->setParameter('to', $dayEnd) ->orderBy('a.slotStart', 'ASC') diff --git a/tests/Treatment/StaffSessionQueueTest.php b/tests/Treatment/StaffSessionQueueTest.php new file mode 100644 index 00000000..f7fb27eb --- /dev/null +++ b/tests/Treatment/StaffSessionQueueTest.php @@ -0,0 +1,211 @@ +get(TreatmentSessionRepository::class); + } + + /** + * @return array{Clinic, ServiceItem, Doctor, TreatmentProtocol} + */ + private function clinicWithProtocol(bool $namesStaff, ?ClinicStaff $allowed = null): array + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر صف'); + $this->em->persist($doctor); + + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک صف ' . uniqid()); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + $section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + // بدون دستهٔ کاتالوگ، نواحیِ پرونده جایی برای snapshot ندارند و باز کردن + // پرونده خطا می‌دهد — همان چیزی که TreatmentCaseOpener انتظار دارد. + $category = new CatalogCategory('clinic', (int) $clinic->getId(), 'صورت'); + $this->em->persist($category); + + $service = new ServiceItem($section, 'لیزر صورت', 3_000_000); + $service->setCatalogCategory($category); + $this->em->persist($service); + $this->em->flush(); + + $protocol = new TreatmentProtocol($service); + $this->em->persist($protocol); + $protocol->replaceSteps([ + new TreatmentProtocolStep($protocol, 1, 0), + new TreatmentProtocolStep($protocol, 2, 15), + ]); + if ($namesStaff && $allowed !== null) { + $protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $allowed)]); + } + $this->em->flush(); + + return [$clinic, $service, $doctor, $protocol]; + } + + private function staffIn(Clinic $clinic, string $name): ClinicStaff + { + $staff = new ClinicStaff('clinic', (int) $clinic->getId(), $name); + $this->em->persist($staff); + $this->em->flush(); + + return $staff; + } + + /** نوبتِ امروز روی سرویسِ پروتکل‌دار، قطعی‌شده تا پرونده و جلسه ساخته شود. */ + private function bookToday(Clinic $clinic, Doctor $doctor, ServiceItem $service, ?ClinicStaff $staff = null): Appointment + { + $start = strtotime('today midnight') + 10 * 3600; + + $appointment = $this->newAppointment( + $doctor, + $this->createUser(['ROLE_USER']), + $start, + $start + 1800, + $clinic, + ); + $appointment->addServiceItem($service); + if ($staff !== null) { + $appointment->setStaff($staff); + } + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appointment); + $this->em->flush(); + + static::getContainer()->get(AppointmentConfirmationService::class)->onConfirmed($appointment); + + return $appointment; + } + + private function today(ClinicStaff $staff): array + { + return $this->repo()->findTodayForStaff( + $staff, + strtotime('today midnight'), + strtotime('tomorrow midnight') - 1, + ); + } + + public function testAnAllowedOperatorSeesUnclaimedWork(): void + { + [$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false); + $operator = $this->staffIn($clinic, 'اپراتور مجاز'); + $this->clinicWithProtocolNamesStaff($service, $operator); + + $appointment = $this->bookToday($clinic, $doctor, $service); + + $sessions = $this->today($operator); + self::assertCount(1, $sessions); + self::assertSame($appointment->getId(), $sessions[0]->getAppointment()?->getId()); + } + + /** پرسنلی که پروتکل نامش را نبرده، کارِ بی‌صاحب را هم نباید ببیند. */ + public function testAnOperatorOutsideTheProtocolSeesNothing(): void + { + [$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false); + $allowed = $this->staffIn($clinic, 'اپراتور مجاز'); + $other = $this->staffIn($clinic, 'اپراتور دیگر'); + $this->clinicWithProtocolNamesStaff($service, $allowed); + + $this->bookToday($clinic, $doctor, $service); + + self::assertCount(1, $this->today($allowed)); + self::assertSame([], $this->today($other)); + } + + /** پروتکلِ بدون فهرست پرسنل یعنی همه مجازند، نه هیچ‌کس. */ + public function testAProtocolWithNoStaffListIsOpenToEveryone(): void + { + [$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false); + $anyone = $this->staffIn($clinic, 'هر اپراتوری'); + + $this->bookToday($clinic, $doctor, $service); + + self::assertCount(1, $this->today($anyone)); + } + + /** اختصاص از قبل توسط منشی: جلسه فقط مالِ همان نفر است. */ + public function testPreAssignedWorkIsHiddenFromOthers(): void + { + [$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false); + $mine = $this->staffIn($clinic, 'اپراتور من'); + $theirs = $this->staffIn($clinic, 'اپراتور آن‌ها'); + + $this->bookToday($clinic, $doctor, $service, staff: $mine); + + self::assertCount(1, $this->today($mine)); + self::assertSame([], $this->today($theirs)); + } + + /** جلسه‌ای که یکی برداشته از صفِ بقیه بیرون می‌رود. */ + public function testClaimedWorkLeavesTheSharedQueue(): void + { + [$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false); + $first = $this->staffIn($clinic, 'اپراتور اول'); + $second = $this->staffIn($clinic, 'اپراتور دوم'); + + $this->bookToday($clinic, $doctor, $service); + + self::assertCount(1, $this->today($first)); + self::assertCount(1, $this->today($second)); + + /** @var TreatmentSession $session */ + $session = $this->today($first)[0]; + $session->setPerformedBy($first); + $this->em->flush(); + + self::assertCount(1, $this->today($first)); + self::assertSame([], $this->today($second)); + } + + /** پرسنلِ کلینیک دیگر هیچ‌وقت این صف را نمی‌بیند. */ + public function testTheQueueIsScopedToTheTenant(): void + { + [$clinic, $service, $doctor] = $this->clinicWithProtocol(namesStaff: false); + $this->bookToday($clinic, $doctor, $service); + + [$otherClinic] = $this->clinicWithProtocol(namesStaff: false); + $outsider = $this->staffIn($otherClinic, 'پرسنل کلینیک دیگر'); + + self::assertSame([], $this->today($outsider)); + } + + /** فهرست پرسنلِ پروتکل بعد از ساختِ سرویس ست می‌شود؛ همان مسیر تنظیمات. */ + private function clinicWithProtocolNamesStaff(ServiceItem $service, ClinicStaff $staff): void + { + $protocol = $this->em->getRepository(TreatmentProtocol::class)->findOneBy(['serviceItem' => $service]); + self::assertNotNull($protocol); + $protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]); + $this->em->flush(); + } +}