error(ErrorCodes::ERR_VALIDATION_001, 'نوع نوبت‌دهی پس از ثبت قابل تغییر نیست', 422, 'booking_mode'); } return null; } /** * در حالت نوبت‌دهی سرویسی، صاحبِ همین context باید حداقل یک سرویسِ * «نمایش در نوبت‌دهی» داشته باشد؛ وگرنه هیچ نوبتی قابل‌محاسبه نیست. * * سرویس‌ها polymorphic‌اند و بین پزشک و کلینیک مشترک نمی‌شوند، پس شمارش باید با * همان (entity_type, entity_id) محیط انجام شود — نه همیشه 'doctor'. */ private function serviceModeHasNoBookable(array $meta, Doctor $doctor, ?Clinic $clinic): bool { if (($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) !== WeeklySchedule::MODE_SERVICE) { return false; } [$type, $id] = $clinic !== null ? [EntityContext::TYPE_CLINIC, $clinic->getId()] : [EntityContext::TYPE_DOCTOR, $doctor->getId()]; return $this->itemRepo->countBookableByEntity($type, $id) === 0; } private function noBookableServiceError(?Clinic $clinic): JsonResponse { $message = $clinic !== null ? 'برای نوبت‌دهی سرویسی، کلینیک باید حداقل یک سرویس با «نمایش در نوبت‌دهی» داشته باشد' : 'برای نوبت‌دهی سرویسی حداقل یک سرویس با «نمایش در نوبت‌دهی» لازم است'; return $this->error(ErrorCodes::ERR_VALIDATION_001, $message, 422, 'booking_mode'); } /** * محیطی که این درخواست در آن اجرا می‌شود: کلینیکِ داده‌شده، یا null یعنی مطب * شخصی پزشک. پزشک حتماً باید عضو آن کلینیک باشد، وگرنه اصلاً چنین محیطی وجود * ندارد. */ private function contextClinic(?string $clinicUuid, Doctor $doctor): ?Clinic { if ($clinicUuid === null || trim($clinicUuid) === '') { return null; } $clinic = $this->clinicRepo->findByUuid(trim($clinicUuid)); if ($clinic === null) { throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); } if (!$clinic->hasDoctor($doctor)) { throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این پزشک عضو کلینیک انتخاب‌شده نیست', 422); } return $clinic; } // ── Weekly Schedule ─────────────────────────────────────────────────────── #[Route('/api/v1/appointment-settings/weekly-schedule', methods: ['POST'])] public function createSchedule(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $doctorUuid = trim($data['doctor_uuid'] ?? ''); $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $clinic = $this->contextClinic($data['clinic_uuid'] ?? null, $doctor); if (($err = $this->denyDoctorAccess($doctor, $user, 'update', $clinic)) !== null) { return $err; } if (($err = $this->validateSessions($data['schedule'] ?? [], $doctor, $clinic)) !== null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, $err, 422); } // یک برنامه به ازای هر context — upsert $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); $prevMode = $schedule?->getStoredBookingMode(); if ($schedule !== null) { $schedule->setSetting($data['schedule'] ?? []); } else { $schedule = new WeeklySchedule($doctor, $data['schedule'] ?? [], $clinic); } if (isset($data['meta']) && is_array($data['meta'])) { $schedule->setMeta($data['meta']); } if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta())) !== null) { return $err; } if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor, $clinic)) { return $this->noBookableServiceError($clinic); } $this->scheduleRepo->save($schedule); return $this->success(['data' => $schedule->toArray()], 201); } #[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['PATCH'])] public function updateSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; // uuid may be doctor uuid or schedule uuid $schedule = $this->scheduleRepo->findByUuid($uuid); if ($schedule === null) { $doctor = $this->doctorRepo->findByUuid($uuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); } $clinic = $this->contextClinic($data['clinic_uuid'] ?? $request->query->get('clinic_uuid'), $doctor); $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); } else { $clinic = $schedule->getClinic(); } if ($schedule === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); } if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update', $clinic)) !== null) { return $err; } $prevMode = $schedule->getStoredBookingMode(); if (isset($data['schedule'])) { if (($err = $this->validateSessions($data['schedule'], $schedule->getDoctor(), $clinic)) !== null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, $err, 422); } $schedule->setSetting($data['schedule']); } if (isset($data['meta']) && is_array($data['meta'])) { $schedule->setMeta($data['meta']); } if (($err = $this->assertModeImmutable($prevMode, $schedule->getMeta())) !== null) { return $err; } if ($this->serviceModeHasNoBookable($schedule->getMeta(), $schedule->getDoctor(), $clinic)) { return $this->noBookableServiceError($clinic); } $this->scheduleRepo->save($schedule); return $this->success(['data' => $schedule->toArray()]); } #[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['GET'])] public function getSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { // Try doctor uuid first, then schedule uuid $doctor = $this->doctorRepo->findByUuid($uuid); if ($doctor !== null) { $clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor); $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); } else { $schedule = $this->scheduleRepo->findByUuid($uuid); $clinic = $schedule?->getClinic(); } if ($schedule === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); } if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'view', $clinic)) !== null) { return $err; } return $this->success(['data' => $schedule->toArray()]); } #[Route('/api/v1/booking-setting/{uuid}', methods: ['DELETE'])] public function deleteSchedule(string $uuid, #[CurrentUser] User $user): JsonResponse { $schedule = $this->scheduleRepo->findByUuid($uuid); if ($schedule === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); } if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update', $schedule->getClinic())) !== null) { return $err; } $this->scheduleRepo->remove($schedule); return $this->success(['message' => 'برنامه هفتگی با موفقیت حذف شد']); } // ── Date Overrides ──────────────────────────────────────────────────────── #[Route('/api/v1/appointment-settings/date-override/list/{doctorUuid}', methods: ['GET'])] public function listOverrides(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor); if (($err = $this->denyDoctorAccess($doctor, $user, 'view', $clinic)) !== null) { return $err; } $overrides = array_map( fn(DateOverride $o) => $o->toArray(), $this->overrideRepo->findByDoctorAndClinic($doctor, $clinic) ); return $this->success(['data' => $overrides]); } #[Route('/api/v1/appointment-settings/date-override', methods: ['POST'])] public function createOverride(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $doctorUuid = trim($data['doctor_uuid'] ?? ''); $dateStr = trim($data['date'] ?? ''); $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $clinic = $this->contextClinic($data['clinic_uuid'] ?? null, $doctor); if (($err = $this->denyDoctorAccess($doctor, $user, 'update', $clinic)) !== null) { return $err; } $timestamp = strtotime($dateStr); if ($timestamp === false || $timestamp === -1) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است', 422, 'date'); } $override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false), $clinic); if (isset($data['reason'])) $override->setReason($data['reason']); if (isset($data['custom_slots'])) $override->setSetting($data['custom_slots']); $this->overrideRepo->save($override); return $this->success(['data' => $override->toArray()], 201); } #[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['PATCH'])] public function updateOverride(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $override = $this->overrideRepo->findByUuid($uuid); if ($override === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404); } if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update', $override->getClinic())) !== null) { return $err; } $data = json_decode($request->getContent(), true) ?? []; if (array_key_exists('active', $data)) $override->setActive((bool) $data['active']); if (array_key_exists('reason', $data)) $override->setReason($data['reason']); if (array_key_exists('custom_slots', $data)) $override->setSetting($data['custom_slots']); if (!empty($data['date'])) { $ts = strtotime($data['date']); if ($ts !== false) $override->setDate($ts); } $this->overrideRepo->save($override); return $this->success(['data' => $override->toArray()]); } #[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['DELETE'])] public function deleteOverride(string $uuid, #[CurrentUser] User $user): JsonResponse { $override = $this->overrideRepo->findByUuid($uuid); if ($override === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404); } if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update', $override->getClinic())) !== null) { return $err; } $this->overrideRepo->remove($override); return $this->success(['message' => 'Override با موفقیت حذف شد']); } #[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['GET'])] public function getOverride(string $uuid, #[CurrentUser] User $user): JsonResponse { $override = $this->overrideRepo->findByUuid($uuid); if ($override === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404); } if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'view', $override->getClinic())) !== null) { return $err; } return $this->success(['data' => $override->toArray()]); } // ── Holidays ────────────────────────────────────────────────────────────── #[Route('/api/v1/appointment-settings/holidays/list/{doctorUuid}', methods: ['GET'])] public function listHolidays(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor); if (($err = $this->denyDoctorAccess($doctor, $user, 'view', $clinic)) !== null) { return $err; } // محیط کلینیک تعطیلی سراسری پزشک را هم می‌بیند (باید بداند پزشک نیست)، اما // editable=false یعنی اجازهٔ تغییرش را ندارد. $items = array_map(function (Holiday $h) use ($clinic): array { $data = $h->toArray(); $data['editable'] = $clinic === null || $h->getClinic() !== null; return $data; }, $this->holidayRepo->findAllByDoctorInContext($doctor, $clinic)); return $this->success(['data' => $items]); } #[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['DELETE'])] public function deleteHoliday(string $uuid, #[CurrentUser] User $user): JsonResponse { $holiday = $this->holidayRepo->findByUuid($uuid); if ($holiday === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404); } if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update', $holiday->getClinic())) !== null) { return $err; } $this->holidayRepo->remove($holiday); return $this->success(['message' => 'تعطیلات حذف شد']); } #[Route('/api/v1/appointment-settings/holidays', methods: ['POST'])] public function createHoliday(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $doctorUuid = trim($data['doctor_uuid'] ?? ''); $startStr = trim($data['start_date'] ?? ''); $endStr = trim($data['end_date'] ?? ''); $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $clinic = $this->contextClinic($data['clinic_uuid'] ?? null, $doctor); if (($err = $this->denyDoctorAccess($doctor, $user, 'update', $clinic)) !== null) { return $err; } // تعطیلی سراسری (بدون clinic_uuid) یعنی «پزشک در هیچ محلی نیست» و مطب شخصی // را هم می‌بندد؛ فقط خود پزشک یا ادمین حق چنین کاری دارد. if ($clinic === null && !$user->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $user->getId()) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'کلینیک فقط می‌تواند تعطیلی مخصوص خودش را ثبت کند', 403, 'clinic_uuid'); } $startTs = strtotime($startStr); $endTs = strtotime($endStr); if (!$startTs || !$endTs || $endTs < $startTs) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ نادرست است', 422); } $holiday = new Holiday($doctor, $startTs, $endTs, $clinic); if (isset($data['reason'])) $holiday->setReason($data['reason']); $this->holidayRepo->save($holiday); return $this->success(['data' => $holiday->toArray()], 201); } #[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['PATCH'])] public function updateHoliday(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $holiday = $this->holidayRepo->findByUuid($uuid); if ($holiday === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404); } if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update', $holiday->getClinic())) !== null) { return $err; } $data = json_decode($request->getContent(), true) ?? []; if (array_key_exists('active', $data)) $holiday->setActive((bool) $data['active']); if (array_key_exists('reason', $data)) $holiday->setReason($data['reason']); if (!empty($data['start_date'])) { $ts = strtotime($data['start_date']); if ($ts) $holiday->setStartDate($ts); } if (!empty($data['end_date'])) { $ts = strtotime($data['end_date']); if ($ts) $holiday->setEndDate($ts); } $this->holidayRepo->save($holiday); return $this->success(['data' => $holiday->toArray()]); } // ── Available Locations ─────────────────────────────────────────────────── #[Route('/api/v1/appointment-settings/available-locations/{doctorUuid}', methods: ['GET'])] public function availableLocations(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $clinic = $this->contextClinic($request->query->get('clinic_uuid'), $doctor); if (($err = $this->denyDoctorAccess($doctor, $user, 'view', $clinic)) !== null) { return $err; } $result = array_map( fn(DoctorAddress $a): array => $a->toArray($clinic?->getName()), $this->addressRepo->findForContext($doctor, $clinic?->getId()) ); return $this->success(['data' => $result]); } /** * تنها نقطهٔ تصمیم‌گیری دربارهٔ «چه کسی تنظیمات نوبت‌دهی این پزشک را می‌بیند/می‌نویسد». * * تصمیم به context وابسته است و نه فقط به شخص: * • مطب شخصی ($clinic === null) فقط برای خود پزشک و ادمین باز است — مالک کلینیک * هیچ کاری با برنامهٔ شخصی پزشک ندارد. * • محیط کلینیک با مجوز appointment_settings همان کلینیک سنجیده می‌شود، نه * حلقه روی همهٔ کلینیک‌های پزشک. * * @param 'view'|'update' $action */ private function denyDoctorAccess(Doctor $doctor, User $user, string $action, ?Clinic $clinic): ?JsonResponse { if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) { return null; } if ($clinic !== null && $this->permChecker->can($user, $clinic, 'appointment_settings', $action)) { return null; } return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } /** * هر شیفت فعال باید آدرسی داشته باشد که به همین context تعلق دارد. بدون بررسی * دوم، کلینیک می‌توانست شیفت را روی آدرس مطب شخصی پزشک بنشاند (و برعکس). */ private function validateSessions(array $schedule, Doctor $doctor, ?Clinic $clinic): ?string { $allowed = []; foreach ($this->addressRepo->findForContext($doctor, $clinic?->getId()) as $address) { $allowed[(string) $address->getId()] = true; } foreach ($schedule as $day) { foreach (($day['sessions'] ?? []) as $session) { if (!($session['active'] ?? false)) { continue; } $locationId = (string) ($session['location_id'] ?? ''); if ($locationId === '') { return 'برای هر شیفت فعال باید آدرس (مطب/کلینیک) انتخاب شود'; } if (!isset($allowed[$locationId])) { return $clinic !== null ? 'آدرس انتخاب‌شده متعلق به این کلینیک نیست' : 'آدرس انتخاب‌شده متعلق به مطب شخصی این پزشک نیست'; } } } return null; } }