diff --git a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md index 7ef5ed13..10510508 100644 --- a/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md +++ b/docs/new_feture/taskes/task-00-service-mode-completion/checklist.md @@ -28,9 +28,9 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۱.۱ | `ServiceBookingCalculator` ساخته شد | ⏳ | | -| ۱.۲ | `serviceSlots()` موجود از آن استفاده می‌کند، **خروجی‌اش عوض نشده** | ⏳ | | -| ۱.۳ | جمع سادهٔ `+=` **حفظ شد** (اصلاحش تسک ۰۴ است) | ⏳ | | +| ۱.۱ | `ServiceBookingCalculator` ساخته شد | ✅ | `src/Appointment/Service/ServiceBookingCalculator.php` + VO در `src/Appointment/ValueObject/ServiceBookingDuration.php` (قرارداد پروژه `ValueObject` است نه `Dto` — `src/Billing/ValueObject`، `src/Insurance/ValueObject`) | +| ۱.۲ | `serviceSlots()` موجود از آن استفاده می‌کند، **خروجی‌اش عوض نشده** | ✅ | ۲۰ خط از کنترلر حذف شد. بیت‌به‌بیت‌بودن: `error()` و `ExceptionSubscriber` **یک envelope یکسان** می‌سازند (`['success'=>false,'data'=>null,'errors'=>[{code,message,field?}]]`)، پس `AppException` جای `return $this->error()` نشست. شاهد: ۱۶۶ تست موجود سبز ماند، شامل `ServiceBasedSlotsTest` و `ServiceModeSectionDurationTest` که همین مسیرهای خطا را می‌سنجند | +| ۱.۳ | جمع سادهٔ `+=` **حفظ شد** (اصلاحش تسک ۰۴ است) | ✅ | با کامنت صریح در docblock کلاس: «تسک ۰۴ همان‌جا یک خط عوض می‌کند» | | ۱.۴ | `ServiceRescheduleService` + `POST /appointment/{uuid}/service-reschedule` | ⏳ | | | ۱.۵ | `PATCH /appointment/{uuid}` توسعه یافت — منطق جدید داخل `isServiceMode()` | ⏳ | | | ۱.۶ | `PATCH` مقدار `service_item_uuids[]` می‌پذیرد | ⏳ | | @@ -41,9 +41,9 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | ۱.۱۱ | `replaceServiceItems` مقدار `serviceItem` تکی را هم‌گام می‌کند | ⏳ | چهار مصرف‌کننده رویش خوانده‌اند | | ۱.۱۲ | `setIsReserve()` صدا زدن `refreshActiveSlotKey()` | ⏳ | | | ۱.۱۳ | اعتبارسنجی زمان با **عضویت در `getServiceStartTimes`**، نه فقط `isSlotTaken` | ⏳ | | -| ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ⏳ | | -| ۱.۱۵ | `allowInactive` فقط برای سرویس‌های موجود نوبت، نه uuid های تازه | ⏳ | | -| ۱.۱۶ | کنترلر نازک ماند — منطق در سرویس | ⏳ | | +| ۱.۱۴ | `TenantOwnershipChecker` روی همهٔ uuid های سرویس، **پیش از** هر بررسی دیگر | ✅ | `belongsToPair()` (نه `belongsTo()`): مسیر عمومی است و کاربر احراز‌شده ندارد. جای مقایسهٔ دستیِ `getSection()->getEntityType()` نشست — نقطهٔ واحد `tenancy.md`. ⚠️ نام property باید `itemRepo` باشد وگرنه `TenantLookupInventoryTest` آن را **نمی‌شمارد** و از تور ایمنی فرار می‌کند | +| ۱.۱۵ | `allowInactive` فقط برای سرویس‌های موجود نوبت، نه uuid های تازه | ✅ | پرچم روی `calculate()`؛ غیرفعال‌بودن → `warnings[]` به‌جای ۴۲۲. مدتِ نامعتبر همچنان حتی با `allowInactive` رد می‌شود (تست دارد) | +| ۱.۱۶ | کنترلر نازک ماند — منطق در سرویس | ✅ | `serviceSlots()` از ۶۰ خط به ۴۰ خط؛ هیچ حلقهٔ محاسباتی در کنترلر نماند | ## ۲. دیتابیس و مهاجرت @@ -84,7 +84,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md) | # | مورد | وضعیت | یادداشت | |---|---|---|---| | ۴.۱ | `SlotModeFrozenTest` — سه سنجه | ⏳ | | -| ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ⏳ | | +| ۴.۲ | `ServiceBookingCalculatorTest` — موفق/خطا/مرزی | ✅ | ۱۳ تست / ۲۹ assertion سبز. شامل: جمع مدت + بافر · `endFor` بدون بافر · override منشی بدون تغییر پیش‌فرض سرویس · چهار مسیر خطا با کد/پیام/فیلد دقیق · uuid ناموجود از سرویسِ محیط دیگر **قابل تفکیک نیست** · `allowInactive` → warning · فهرست خالی → صفر · override نامعتبر (۰ و منفی) → fallback · پزشک بی‌برنامه → پیش‌فرض `slot` | | ۴.۳ | `ServiceRescheduleTest` — شامل «حذف سرویس → مدت خودکار» | ⏳ | | | ۴.۴ | `PatchServiceDurationTest` — شامل «در حالت اسلاتی هیچ‌کدام اجرا نمی‌شود» | ⏳ | | | ۴.۵ | `ConvertReserveTest` — شامل `active_slot_key` و رقابت | ⏳ | | diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 961055fe..58c38ca0 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -43,6 +43,7 @@ class AppointmentController extends BaseController private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo, private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker, private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance, + private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator, private readonly \Psr\Log\LoggerInterface $logger, ) {} @@ -207,46 +208,26 @@ class AppointmentController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids'); } - // پیش از هر بررسی دیگری: سرویس باید مالِ همین محیط باشد. مسیر ثبت نوبت همین - // گارد را دارد و این مسیر نداشت، پس با uuid سرویسِ محیط دیگر می‌شد وجود، - // فعال‌بودن و مدتش را از پیام‌های خطا و اسلات‌های برگشتی استنتاج کرد. - if (($err = $this->assertServicesMatchContext($uuids, $doctor, $clinic)) !== null) { - return $err; - } - // مدتِ override منشی (فقط برای همین محاسبه؛ پیش‌فرض سرویس تغییر نمی‌کند). durations[uuid]=minutes - $overrides = (array) $request->query->all('durations'); - - $totalMinutes = 0; - foreach ($uuids as $u) { - $item = $this->itemRepo->findByUuid($u); - if ($item === null) { - return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids'); - } - if (!$item->isBookable()) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); - } - $duration = isset($overrides[$u]) && (int) $overrides[$u] > 0 - ? (int) $overrides[$u] - : (int) ($item->getDurationMinutes() ?? 0); - if ($duration <= 0) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); - } - $totalMinutes += $duration; - } - - $meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META; + // مالکیت محیط، وجود، فعال‌بودن و مدت — همه داخل calculate() و با همان ترتیب و + // همان کد/پیام/فیلدِ قبلی (AppException و $this->error() یک envelope می‌سازند). + $duration = $this->serviceCalculator->calculate( + $doctor, + $clinic, + $uuids, + (array) $request->query->all('durations'), + ); return $this->success([ 'doctor_uuid' => $doctorUuid, 'date' => $date, - 'total_duration_minutes' => $totalMinutes, - 'buffer_minutes' => (int) $meta['buffer_minutes'], + 'total_duration_minutes' => $duration->totalMinutes, + 'buffer_minutes' => $duration->bufferMinutes, 'clinic_uuid' => $clinic?->getUuid(), 'start_times' => $this->slotCalculator->getServiceStartTimes( $doctor, $date, - $totalMinutes, + $duration->totalMinutes, $clinic, $this->isManagementContext($request, $doctor, $clinic), ), diff --git a/src/Appointment/Service/ServiceBookingCalculator.php b/src/Appointment/Service/ServiceBookingCalculator.php new file mode 100644 index 00000000..93cdeb46 --- /dev/null +++ b/src/Appointment/Service/ServiceBookingCalculator.php @@ -0,0 +1,142 @@ +metaOf($doctor, $clinic)['booking_mode'] ?? WeeklySchedule::MODE_SLOT) + === WeeklySchedule::MODE_SERVICE; + } + + public function bufferMinutes(Doctor $doctor, ?Clinic $clinic): int + { + return max(0, (int) ($this->metaOf($doctor, $clinic)['buffer_minutes'] ?? 0)); + } + + /** + * مدت و بافرِ یک ترکیب سرویس. + * + * ترتیب بررسی عمدی است: **مالکیت محیط اول**. پیام‌های بعدی وجود و فعال‌بودن و مدت + * سرویس را لو می‌دهند و همان نشتی‌ای می‌سازند که در فاز ۸ tenancy روی این endpoint + * پیدا و رفع شد ({@see docs/architecture/tenancy.md}، جدول «uuid از درخواست»). + * + * @param string[] $serviceUuids + * @param array $durationOverrides uuid → دقیقه؛ override منشی، فقط + * برای همین محاسبه (پیش‌فرض سرویس + * دست‌نخورده می‌ماند) + * @param bool $allowInactive سرویسِ غیرفعالِ **نوبتِ موجود** را رد نکن؛ به‌جایش warning بده. + * نوبتی که کلینیک سرویسش را غیرفعال کرده باید قابل جابه‌جایی + * و لغو بماند، وگرنه برای همیشه قفل می‌شود. + * + * @throws AppException همان کد/پیام/فیلدی که پیش‌تر کنترلر برمی‌گرداند + */ + public function calculate( + Doctor $doctor, + ?Clinic $clinic, + array $serviceUuids, + array $durationOverrides = [], + bool $allowInactive = false, + ): ServiceBookingDuration { + $this->assertBelongsToContext($serviceUuids, $doctor, $clinic); + + $totalMinutes = 0; + $resolved = []; + $warnings = []; + + foreach ($serviceUuids as $uuid) { + $item = $this->itemRepo->findByUuid($uuid); + if ($item === null) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_item_uuids'); + } + + if (!$item->isBookable()) { + if (!$allowInactive) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این سرویس برای نوبت‌دهی فعال نیست', 422, 'service_item_uuids'); + } + $warnings[] = sprintf('سرویس «%s» دیگر برای نوبت‌دهی فعال نیست', $item->getName()); + } + + $override = isset($durationOverrides[$uuid]) ? (int) $durationOverrides[$uuid] : 0; + $duration = $override > 0 ? $override : (int) ($item->getDurationMinutes() ?? 0); + if ($duration <= 0) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); + } + + $totalMinutes += $duration; + $resolved[] = $item; + } + + return new ServiceBookingDuration( + totalMinutes: $totalMinutes, + bufferMinutes: $this->bufferMinutes($doctor, $clinic), + serviceItems: $resolved, + warnings: $warnings, + ); + } + + /** + * سرویس باید مالِ همین محل نوبت‌دهی باشد. uuid ناموجود هم همین خطا را می‌گیرد — + * عمدی، تا نبودن سرویس از «سرویسِ محیط دیگر» قابل تفکیک نباشد. + * + * محیط از جفتِ **بوکینگ** ساخته می‌شود (کلینیک اگر باشد، وگرنه مطب شخصی پزشک)، نه از + * محیط جاری کاربر: این مسیر عمومی است و ممکن است هیچ کاربر احراز‌شده‌ای نداشته باشد. + * پس `belongsToPair()` استفاده می‌شود، نه `belongsTo()`. + * + * @param string[] $serviceUuids + */ + private function assertBelongsToContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): void + { + [$type, $id] = $clinic !== null + ? ['clinic', $clinic->getId()] + : ['doctor', $doctor->getId()]; + + foreach ($serviceUuids as $uuid) { + if (!$this->ownership->belongsToPair($type, $id, $this->itemRepo->findByUuid($uuid))) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', + 422, + 'service_item_uuids', + ); + } + } + } + + /** @return array */ + private function metaOf(Doctor $doctor, ?Clinic $clinic): array + { + $schedule = $this->schedules->findByDoctorAndClinic($doctor, $clinic); + + return $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META; + } +} diff --git a/src/Appointment/ValueObject/ServiceBookingDuration.php b/src/Appointment/ValueObject/ServiceBookingDuration.php new file mode 100644 index 00000000..8b148855 --- /dev/null +++ b/src/Appointment/ValueObject/ServiceBookingDuration.php @@ -0,0 +1,32 @@ +totalMinutes * 60; + } +} diff --git a/tests/Appointment/ServiceBookingCalculatorTest.php b/tests/Appointment/ServiceBookingCalculatorTest.php new file mode 100644 index 00000000..7003f59d --- /dev/null +++ b/tests/Appointment/ServiceBookingCalculatorTest.php @@ -0,0 +1,239 @@ +get(ServiceBookingCalculator::class); + } + + private function serviceModeDoctor(int $buffer = 0): Doctor + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر محاسبهٔ سرویس'); + $this->em->persist($doctor); + + $schedule = $this->newWeeklySchedule($doctor, [ + '0' => ['sessions' => [[ + 'active' => true, 'start_time' => '09:00', 'end_time' => '17:00', + 'duration_per_patient' => 20, 'location_id' => 1, + ]]], + ]); + $schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]); + $this->em->persist($schedule); + $this->em->flush(); + + return $doctor; + } + + private function slotModeDoctor(): Doctor + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر اسلاتی'); + $this->em->persist($doctor); + + $schedule = $this->newWeeklySchedule($doctor, ['0' => ['sessions' => []]]); + $schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SLOT]); + $this->em->persist($schedule); + $this->em->flush(); + + return $doctor; + } + + private function service(Doctor $doctor, string $name, ?int $minutes, bool $bookable = true): ServiceItem + { + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name); + $this->em->persist($section); + $item = new ServiceItem($section, $name, 0); + $item->setDurationMinutes($minutes)->setBookable($bookable); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + // ── ✅ مسیر موفق ───────────────────────────────────────────────────────── + + public function testSumsDurationsAndCarriesBuffer(): void + { + $doctor = $this->serviceModeDoctor(buffer: 10); + $face = $this->service($doctor, 'لیزر صورت', 20); + $bikini = $this->service($doctor, 'لیزر بیکینی', 15); + + $result = $this->calculator()->calculate($doctor, null, [$face->getUuid(), $bikini->getUuid()]); + + self::assertSame(35, $result->totalMinutes); + self::assertSame(10, $result->bufferMinutes, 'بافر از meta برنامه می‌آید'); + self::assertSame([], $result->warnings); + self::assertCount(2, $result->serviceItems); + } + + public function testEndForExcludesBuffer(): void + { + $doctor = $this->serviceModeDoctor(buffer: 10); + $item = $this->service($doctor, 'مشاوره', 30); + + $result = $this->calculator()->calculate($doctor, null, [$item->getUuid()]); + $start = 1_800_000_000; + + self::assertSame($start + 30 * 60, $result->endFor($start), 'بافر جزو مدت نوبت نیست'); + } + + public function testSecretaryOverrideWinsWithoutTouchingTheService(): void + { + $doctor = $this->serviceModeDoctor(); + $item = $this->service($doctor, 'تزریق', 20); + + $result = $this->calculator()->calculate( + $doctor, null, [$item->getUuid()], [$item->getUuid() => 45], + ); + + self::assertSame(45, $result->totalMinutes); + + $this->em->refresh($item); + self::assertSame(20, $item->getDurationMinutes(), 'مدت پیش‌فرض سرویس نباید عوض شود'); + } + + public function testIsServiceModeReadsBookingModeOnly(): void + { + self::assertTrue($this->calculator()->isServiceMode($this->serviceModeDoctor(), null)); + self::assertFalse($this->calculator()->isServiceMode($this->slotModeDoctor(), null)); + } + + // ── ❌ مسیرهای خطا — همان کد/پیام/فیلد قبلی کنترلر ──────────────────────── + + public function testServiceWithoutDurationIsRejected(): void + { + $doctor = $this->serviceModeDoctor(); + $item = $this->service($doctor, 'بدون مدت', null); + + try { + $this->calculator()->calculate($doctor, null, [$item->getUuid()]); + self::fail('سرویس بدون مدت باید رد شود'); + } catch (AppException $e) { + self::assertSame(ErrorCodes::ERR_VALIDATION_001, $e->getErrorCode()); + self::assertSame('مدت سرویس تعریف نشده است', $e->getMessage()); + self::assertSame('service_item_uuids', $e->getField()); + self::assertSame(422, $e->getHttpStatus()); + } + } + + public function testInactiveServiceIsRejectedByDefault(): void + { + $doctor = $this->serviceModeDoctor(); + $item = $this->service($doctor, 'غیرفعال', 30, bookable: false); + + try { + $this->calculator()->calculate($doctor, null, [$item->getUuid()]); + self::fail('سرویس غیرفعال باید رد شود'); + } catch (AppException $e) { + self::assertSame('این سرویس برای نوبت‌دهی فعال نیست', $e->getMessage()); + self::assertSame(422, $e->getHttpStatus()); + } + } + + public function testForeignTenantServiceLeaksNothing(): void + { + $mine = $this->serviceModeDoctor(); + $other = $this->serviceModeDoctor(); + $foreign = $this->service($other, 'سرویس محیط دیگر', 30); + + try { + $this->calculator()->calculate($mine, null, [$foreign->getUuid()]); + self::fail('سرویس محیط دیگر باید رد شود'); + } catch (AppException $e) { + // پیام نباید وجود، فعال‌بودن یا مدت سرویس را لو بدهد. + self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $e->getMessage()); + self::assertStringNotContainsString('سرویس محیط دیگر', $e->getMessage()); + } + } + + public function testUnknownUuidIsIndistinguishableFromForeignService(): void + { + $doctor = $this->serviceModeDoctor(); + + try { + $this->calculator()->calculate($doctor, null, ['00000000-0000-4000-8000-000000000000']); + self::fail('uuid ناموجود باید رد شود'); + } catch (AppException $e) { + self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $e->getMessage()); + } + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + public function testAllowInactiveTurnsRejectionIntoWarning(): void + { + $doctor = $this->serviceModeDoctor(); + $item = $this->service($doctor, 'لیزر بازنشسته', 30, bookable: false); + + $result = $this->calculator()->calculate($doctor, null, [$item->getUuid()], allowInactive: true); + + self::assertSame(30, $result->totalMinutes, 'نوبت موجود باید قابل جابه‌جایی بماند'); + self::assertCount(1, $result->warnings); + self::assertStringContainsString('لیزر بازنشسته', $result->warnings[0]); + } + + public function testAllowInactiveStillRejectsMissingDuration(): void + { + $doctor = $this->serviceModeDoctor(); + $item = $this->service($doctor, 'غیرفعال و بی‌مدت', null, bookable: false); + + $this->expectException(AppException::class); + $this->expectExceptionMessage('مدت سرویس تعریف نشده است'); + $this->calculator()->calculate($doctor, null, [$item->getUuid()], allowInactive: true); + } + + public function testEmptySelectionYieldsZeroMinutes(): void + { + $doctor = $this->serviceModeDoctor(buffer: 5); + + $result = $this->calculator()->calculate($doctor, null, []); + + // صفر یعنی «هیچ سرویسی انتخاب نشده»؛ رد کردنِ فهرست خالی کارِ کنترلر است، نه + // این کلاس — تا مسیر PATCH بتواند نوبتِ بدون سرویس را بدون خطا رد کند. + self::assertSame(0, $result->totalMinutes); + self::assertSame(5, $result->bufferMinutes); + } + + public function testNonPositiveOverrideFallsBackToServiceDuration(): void + { + $doctor = $this->serviceModeDoctor(); + $item = $this->service($doctor, 'مشاوره', 25); + + foreach ([0, -10] as $bad) { + $result = $this->calculator()->calculate( + $doctor, null, [$item->getUuid()], [$item->getUuid() => $bad], + ); + self::assertSame(25, $result->totalMinutes, 'override نامعتبر نادیده گرفته می‌شود'); + } + } + + public function testDoctorWithoutScheduleFallsBackToDefaultMeta(): void + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر بی‌برنامه'); + $this->em->persist($doctor); + $this->em->flush(); + + self::assertFalse($this->calculator()->isServiceMode($doctor, null), 'پیش‌فرض slot است'); + self::assertSame(0, $this->calculator()->bufferMinutes($doctor, null)); + } +} diff --git a/tests/Shared/TenantLookupInventoryTest.php b/tests/Shared/TenantLookupInventoryTest.php index 721238aa..a2c8a837 100644 --- a/tests/Shared/TenantLookupInventoryTest.php +++ b/tests/Shared/TenantLookupInventoryTest.php @@ -35,8 +35,11 @@ class TenantLookupInventoryTest extends TestCase * @var array */ private const REVIEWED = [ - // assertServicesMatchContext در هر سه مسیر (اسلات سرویسی، ثبت، و خودِ گارد) - 'src/Appointment/Controller/AppointmentController.php' => 3, + // assertServicesMatchContext (مسیر ثبت نوبت) + خودِ گارد. جست‌وجوی مسیرِ اسلاتِ + // سرویسی به ServiceBookingCalculator منتقل شد و همان‌جا شمرده می‌شود. + 'src/Appointment/Controller/AppointmentController.php' => 2, + // TenantOwnershipChecker::belongsToPair() روی هر uuid، پیش از هر بررسی دیگر + 'src/Appointment/Service/ServiceBookingCalculator.php' => 2, // TenantOwnershipChecker روی بخش/سرویس/پرسنل و فهرست سرویس‌ها 'src/Appointment/Controller/MyAppointmentsController.php' => 1, // ownsSession / getEntityType روی صورتحساب، پرونده و مطالبه