getRealName() ?: $user->getMobileNumber(); $event = new \App\Appointment\Entity\AppointmentEvent($appointment, \App\Appointment\Entity\AppointmentEvent::TYPE_CANCELLED, 'نوبت لغو شد'); $event->setActor($user->getId(), $actorName); $event->setReason($reason); $this->eventRepo->save($event); $this->logger->warning(sprintf( 'Appointment cancelled: uuid=%s status=%s by user=%d(%s) reason=%s', $appointment->getUuid(), $status, (int) $user->getId(), $actorName, $reason ?? '-' )); } // ── Public: available slots ─────────────────────────────────────────────── #[OA\Get( path: '/api/v1/appointment-slots', summary: 'Get available appointment slots for a doctor on a given date', parameters: [ new OA\Parameter( name: 'doctor_uuid', in: 'query', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), new OA\Parameter( name: 'date', in: 'query', required: true, description: 'Date in Y-m-d format', schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15') ), ], responses: [ new OA\Response( response: 200, description: 'Available slots returned', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', properties: [ new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'), new OA\Property(property: 'date', type: 'string', format: 'date'), new OA\Property( property: 'slots', type: 'array', items: new OA\Items( properties: [ new OA\Property(property: 'start', type: 'integer', description: 'Unix timestamp'), new OA\Property(property: 'end', type: 'integer', description: 'Unix timestamp'), new OA\Property(property: 'available', type: 'boolean'), ], type: 'object' ) ), ], type: 'object' ), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response( response: 404, description: 'Doctor not found', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: false), new OA\Property( property: 'errors', type: 'array', items: new OA\Items( properties: [ new OA\Property(property: 'code', type: 'string'), new OA\Property(property: 'message', type: 'string'), ], type: 'object' ) ), ] ) ), new OA\Response(response: 422, description: 'Invalid date format'), ] )] #[Route('/api/v1/appointment-slots', methods: ['GET'])] public function slots(Request $request): JsonResponse { $doctorUuid = trim($request->query->get('doctor_uuid', '')); $date = trim($request->query->get('date', '')); $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date'); } $clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid')); $forManagement = $this->isManagementContext($request, $doctor, $clinic); $sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date, $clinic, $forManagement); return $this->success([ 'doctor_uuid' => $doctorUuid, 'clinic_uuid' => $clinic?->getUuid(), 'date' => $date, 'sessions' => $sessions, // خالی‌بودن دلایل مختلفی دارد؛ کلاینت نباید همه را «تعطیل» بنامد. 'empty_reason' => $sessions === [] ? $this->slotCalculator->explainEmptyDay($doctor, $date, $clinic, $forManagement) : null, ]); } /** * حالت نوبت‌دهی سرویسی: زمان‌های خالیِ کافی برای مجموعِ مدت سرویس‌های انتخاب‌شده. * فقط سرویس‌های «نمایش در نوبت‌دهی» (bookable) و دارای مدت پذیرفته می‌شوند. * * GET /api/v1/appointment-service-slots?doctor_uuid=..&date=Y-m-d&service_item_uuids[]=.. */ #[Route('/api/v1/appointment-service-slots', methods: ['GET'])] public function serviceSlots(Request $request): JsonResponse { $doctorUuid = trim($request->query->get('doctor_uuid', '')); $date = trim($request->query->get('date', '')); $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date'); } $clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid')); $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); $mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT; if ($mode !== WeeklySchedule::MODE_SERVICE) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبت‌دهی سرویسی نیست', 422); } $uuids = array_values(array_filter(array_map('trim', (array) $request->query->all('service_item_uuids')))); if (empty($uuids)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids'); } // مدتِ override منشی (فقط برای همین محاسبه؛ پیش‌فرض سرویس تغییر نمی‌کند). durations[uuid]=minutes // مالکیت محیط، وجود، فعال‌بودن و مدت — همه داخل calculate() و با همان ترتیب و // همان کد/پیام/فیلدِ قبلی (AppException و $this->error() یک envelope می‌سازند). $duration = $this->serviceCalculator->calculate( $doctor, $clinic, $uuids, (array) $request->query->all('durations'), ); // جابه‌جایی: بازهٔ خودِ نوبتِ در حال ویرایش نباید اشغال حساب شود، وگرنه زمان // فعلی‌اش هرگز در فهرست نمی‌آید. فقط برای کسی که همان نوبت را مدیریت می‌کند — // این پارامتر آزاد نیست، چون در غیر این‌صورت هر کسی می‌توانست با uuid دلخواه // ظرفیتِ ساختگی ببیند. $excludeId = null; $excludeUuid = trim((string) $request->query->get('exclude_appointment_uuid', '')); if ($excludeUuid !== '') { $excluded = $this->appointmentRepo->findByUuid($excludeUuid); $actor = $this->getUser(); if ($excluded === null || !$actor instanceof User || !$this->canManage($excluded, $actor)) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403, 'exclude_appointment_uuid'); } if ($excluded->getDoctor()->getId() !== $doctor->getId()) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوبت انتخابی به این پزشک تعلق ندارد', 422, 'exclude_appointment_uuid'); } $excludeId = $excluded->getId(); } return $this->success([ 'doctor_uuid' => $doctorUuid, 'date' => $date, 'total_duration_minutes' => $duration->totalMinutes, 'buffer_minutes' => $duration->bufferMinutes, 'clinic_uuid' => $clinic?->getUuid(), 'start_times' => $this->slotCalculator->getServiceStartTimes( $doctor, $date, $duration->totalMinutes, $clinic, $this->isManagementContext($request, $doctor, $clinic), $excludeId, ), ]); } /** * عمومی: روش نوبت‌دهی پزشک + سرویس‌های قابل‌انتخاب برای نوبت‌گیری سرویسی. * سایت با این پاسخ تصمیم می‌گیرد مرحلهٔ انتخاب سرویس را نشان دهد یا جریان اسلاتی. * * GET /api/v1/appointment-booking-services/{doctorUuid} */ #[Route('/api/v1/appointment-booking-services/{doctorUuid}', methods: ['GET'])] public function bookingServices(string $doctorUuid, Request $request): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid')); $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); $meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META; return $this->success([ 'doctor_uuid' => $doctorUuid, 'clinic_uuid' => $clinic?->getUuid(), 'booking_mode' => $meta['booking_mode'], 'buffer_minutes' => (int) $meta['buffer_minutes'], 'services' => $this->bookableServices($doctor, $clinic), ]); } /** * عمومی: همهٔ محل‌های نوبت‌دهی یک پزشک — مطب شخصی و هر کلینیکی که در آن برنامهٔ * فعال دارد. سایت باید همه را نشان دهد؛ انتخاب یکی و پنهان‌کردن بقیه یعنی حذف * بخشی از ظرفیت واقعی پزشک. * * GET /api/v1/appointment-booking-locations/{doctorUuid} */ #[Route('/api/v1/appointment-booking-locations/{doctorUuid}', methods: ['GET'])] public function bookingLocations(string $doctorUuid, Request $request): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $date = trim((string) $request->query->get('date', '')); if ($date !== '' && !$this->isCalendarDate($date)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date'); } $locations = []; foreach ($this->scheduleRepo->findAllByDoctor($doctor) as $schedule) { $clinic = $schedule->getClinic(); $addresses = $this->addressRepo->findForContext($doctor, $clinic?->getId()); // محلی که آدرسی ندارد، محل نیست — چیزی برای مراجعهٔ بیمار وجود ندارد. if ($addresses === []) { continue; } $byId = []; foreach ($addresses as $a) { $byId[(int) $a->getId()] = $a; } // و برنامه‌ای که هیچ شیفتش روی آدرس‌های همین محیط ننشسته، قابل رزرو نیست. $hours = $this->openingHours($schedule, $byId); if ($hours === []) { continue; } $meta = $schedule->getMeta(); $address = $byId[$hours[0]['location_id']] ?? $addresses[0]; $forManagement = $this->isManagementContext($request, $doctor, $clinic); $locations[] = [ 'location_uuid' => $address->getUuid(), 'type' => $clinic === null ? 'personal' : 'clinic', 'title' => $clinic?->getName() ?? ($address->getName() ?: 'مطب شخصی'), 'address' => $address->getAddress(), 'clinic_uuid' => $clinic?->getUuid(), 'booking_mode' => $meta['booking_mode'], 'buffer_minutes' => (int) $meta['buffer_minutes'], 'opening_hours' => $hours, 'services' => $meta['booking_mode'] === WeeklySchedule::MODE_SERVICE ? $this->bookableServices($doctor, $clinic) : [], 'next_available_at' => $this->slotCalculator->findNextAvailableStart($doctor, $clinic, 30, $forManagement), 'available_on_date' => $date === '' ? null : $this->slotCalculator->getAvailableSlots($doctor, $date, $clinic, $forManagement) !== [], ]; } // پیش‌فرضِ سایت = زودترین نوبت آزاد؛ محل‌های بدون ظرفیت به انتها می‌روند. usort($locations, fn(array $a, array $b) => ($a['next_available_at'] ?? PHP_INT_MAX) <=> ($b['next_available_at'] ?? PHP_INT_MAX)); return $this->success([ 'doctor_uuid' => $doctorUuid, 'date' => $date !== '' ? $date : null, 'booking_locations' => $locations, ]); } #[Route('/api/v1/appointment-settings/month-availability/{doctorUuid}', methods: ['GET'])] public function monthAvailability(string $doctorUuid, Request $request): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $year = (int) $request->query->get('year'); $month = (int) $request->query->get('month'); if ($year < 1970 || $month < 1 || $month > 12) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سال یا ماه نامعتبر است', 422, 'month'); } $clinic = $this->bookingClinic($doctor, $request->query->get('clinic_uuid')); $forManagement = $this->isManagementContext($request, $doctor, $clinic); $daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month))); $disabled = []; $enabled = []; for ($day = 1; $day <= $daysInMonth; $day++) { $date = sprintf('%04d-%02d-%02d', $year, $month, $day); if ($this->slotCalculator->hasAnyAvailability($doctor, $date, $clinic, $forManagement)) { $enabled[] = $date; } else { $disabled[] = $date; } } $schedule = $this->scheduleRepo->findByDoctorAndClinic($doctor, $clinic); $meta = $schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META; return $this->success([ 'year' => $year, 'clinic_uuid' => $clinic?->getUuid(), 'month' => $month, 'disabled_dates' => $disabled, 'enabled_dates' => $enabled, 'online_booking_enabled' => (bool) $meta['online_booking_enabled'], 'booking_window' => [ 'value' => (int) $meta['booking_window_value'], 'unit' => $meta['booking_window_unit'], ], ]); } // ── Authenticated: book / manage ───────────────────────────────────────── #[OA\Post( path: '/api/v1/appointment', summary: 'Book a new appointment', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['doctor_uuid', 'slot_start', 'slot_end'], properties: [ new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'), new OA\Property(property: 'slot_start', type: 'integer', description: 'Slot start Unix timestamp'), new OA\Property(property: 'slot_end', type: 'integer', description: 'Slot end Unix timestamp'), new OA\Property(property: 'note', type: 'string'), ] ) ), responses: [ new OA\Response( response: 201, description: 'Appointment booked successfully', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Appointment object'), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response(response: 401, description: 'Unauthenticated'), new OA\Response(response: 404, description: 'Doctor not found'), new OA\Response(response: 409, description: 'Slot already taken'), new OA\Response(response: 422, description: 'Validation error'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointment', methods: ['POST'])] public function book(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; $doctorUuid = trim($data['doctor_uuid'] ?? ''); $slotStart = (int) ($data['slot_start'] ?? 0); $slotEnd = (int) ($data['slot_end'] ?? 0); $clinicUuid = $data['clinic_uuid'] ?? null; // حالت نوبت‌دهی سرویسی: مدت نوبت = مجموع مدت سرویس‌های bookableِ انتخاب‌شده، // و slot_end سمت سرور محاسبه می‌شود (به مقدار کلاینت اعتماد نمی‌شود). $serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? [])))); $serviceItem = null; if (!empty($serviceUuids)) { $totalMinutes = 0; foreach ($serviceUuids 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'); } if (($item->getDurationMinutes() ?? 0) <= 0) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids'); } $totalMinutes += (int) $item->getDurationMinutes(); $serviceItem ??= $item; } $slotEnd = $slotStart + $totalMinutes * 60; } if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422); } if ($slotStart < time()) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'زمان این اسلات گذشته است', 422); } $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $bookingClinic = $this->bookingClinic($doctor, $clinicUuid); // سرویس باید متعلق به همان محلی باشد که نوبت در آن ثبت می‌شود؛ وگرنه بیمار // می‌توانست سرویس کلینیک را روی نوبت مطب شخصی بنشاند. if ($serviceItem !== null && ($err = $this->assertServicesMatchContext($serviceUuids, $doctor, $bookingClinic)) !== null) { return $err; } $forSelf = (bool) ($data['for_self'] ?? true); // کد ملی و جنسیت بیمار همیشه الزامی است (چه برای خود، چه برای دیگری). $nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? ''))); // فرانت‌اند male/female می‌فرستد؛ به فرمِ متعارفِ بک‌اند (man/woman) نگاشت می‌شود. $gender = match (strtolower(trim((string) ($data['patient_gender'] ?? '')))) { 'man', 'male' => 'man', 'woman', 'female' => 'woman', default => '', }; if ($nationalCode === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی بیمار الزامی است', 422, 'patient_national_code'); } if (!InputValidator::isValidIranNationalCode($nationalCode)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'patient_national_code'); } if ($gender === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'جنسیت بیمار الزامی است', 422, 'patient_gender'); } $appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); $appointment->setPatientNationalCode($nationalCode); $appointment->setPatientGender($gender); if ($serviceItem !== null) $appointment->setServiceItem($serviceItem); if (isset($data['note'])) $appointment->setNote($data['note']); // نماینده‌ی دامنه‌ی مبدأ رزرو (از Origin مرورگر)؛ گاردِ نهایی پورسانت در لحظه‌ی // پرداخت دوباره از payment.frontend_address محاسبه می‌شود — این فقط ثبتِ لحظه‌ی رزرو است. $bookingCtx = $this->domainResolver->resolve($request->headers->get('origin')); if ($bookingCtx->representationId() !== null) { $appointment->setBookingRepresentationId($bookingCtx->representationId()); } // آدرس نوبت از روی session متناظر در برنامه‌ی هفتگی تعیین می‌شود (location_id). $appointment->setClinic($bookingClinic); $appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $bookingClinic)); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic); if ($locationId !== null) { $appointment->setAddressId($locationId); } if ($forSelf) { $appointment->setPatientName($user->getRealName()); $appointment->setPatientMobile($user->getMobileNumber()); } else { $patientName = trim($data['patient_name'] ?? ''); $patientMobile = trim($data['patient_mobile'] ?? ''); if ($patientName === '' || $patientMobile === '') { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام و شماره موبایل بیمار الزامی است', 422); } $appointment->setPatientName($patientName); $appointment->setPatientMobile($patientMobile); $appointment->setPatientReason($data['patient_reason'] ?? null); } $appointment->markPendingWithTtl(Appointment::PAYMENT_TTL); try { $this->appointmentRepo->bookAtomically($appointment); } catch (SlotTakenException) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409); } return $this->success(['data' => $appointment->toArray()], 201); } #[OA\Get( path: '/api/v1/appointment/{uuid}', summary: 'Get a single appointment by UUID', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter( name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), ], responses: [ new OA\Response( response: 200, description: 'Appointment returned', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Appointment object'), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response(response: 401, description: 'Unauthenticated'), new OA\Response(response: 403, description: 'Access denied'), new OA\Response(response: 404, description: 'Appointment not found'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointment/{uuid}', methods: ['GET'])] public function get(string $uuid, #[CurrentUser] User $user): JsonResponse { $appointment = $this->appointmentRepo->findByUuid($uuid); if ($appointment === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); } if (!$this->canView($appointment, $user)) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } return $this->success(['data' => $appointment->toArray()]); } #[OA\Get( path: '/api/v1/appointments/doctor/{doctorUuid}', summary: 'List appointments for a specific doctor', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter( name: 'doctorUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), new OA\Parameter( name: 'status', in: 'query', required: false, description: 'Filter by appointment status', schema: new OA\Schema(type: 'string', example: 'pending') ), ], responses: [ new OA\Response( response: 200, description: 'Appointment list returned', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', type: 'array', items: new OA\Items(type: 'object', description: 'Appointment object') ), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response(response: 401, description: 'Unauthenticated'), new OA\Response(response: 403, description: 'Access denied'), new OA\Response(response: 404, description: 'Doctor not found'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointments/doctor/{doctorUuid}', methods: ['GET'])] public function listByDoctor(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse { $doctor = $this->doctorRepo->findByUuid($doctorUuid); if ($doctor === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } $isOwner = $doctor->getUser()->getId() === $user->getId() || $user->hasRole('ROLE_ADMIN'); // مدیر کلینیک لیست پزشک عضو را می‌بیند، ولی فقط نوبت‌های همان کلینیک — // نوبت‌های مطب شخصی پزشک به کلینیک نشت نمی‌کند. $scopeClinic = $isOwner ? null : $this->accessChecker->viewableClinicFor($user, $doctor); if (!$isOwner && $scopeClinic === null) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } // بدون هیچ پارامتر فیلتر/صفحه‌بندی، رفتار قدیمی (لیست کامل، پاسخ تودرتو) حفظ // می‌شود تا کلاینت‌های موجود نشکنند. با هر فیلتری پاسخ صفحه‌بندی‌شده می‌آید. $filterKeys = ['statuses', 'from', 'to', 'q', 'service_uuid', 'page', 'limit']; $isFiltered = (bool) array_filter($filterKeys, fn(string $k) => $request->query->has($k)); if (!$isFiltered) { $status = $request->query->get('status'); $appointments = $this->appointmentRepo->findByDoctor($doctor, $status, $scopeClinic); return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]); } // هم `statuses[]=a&statuses[]=b` و هم `statuses=a,b` پذیرفته می‌شود؛ سینتکس // دوم بدون براکت در Symfony به رشته تبدیل می‌شود و all() استثنا می‌دهد. $rawStatuses = $request->query->has('statuses') ? $request->query->all()['statuses'] : []; $statuses = is_array($rawStatuses) ? $rawStatuses : explode(',', (string) $rawStatuses); if ($statuses === [] && $request->query->get('status')) { $statuses = [$request->query->get('status')]; } $page = max(1, (int) $request->query->get('page', 1)); $limit = min(100, max(1, (int) $request->query->get('limit', 20))); $result = $this->appointmentRepo->searchByDoctor( doctor: $doctor, statuses: array_values(array_filter($statuses, fn($s) => is_string($s) && $s !== '')), clinic: $scopeClinic, from: $request->query->has('from') ? (int) $request->query->get('from') : null, to: $request->query->has('to') ? (int) $request->query->get('to') : null, query: $request->query->get('q'), serviceUuid: $request->query->get('service_uuid'), page: $page, limit: $limit, ); return $this->paginated( array_map(fn(Appointment $a) => $a->toArray(), $result['items']), $result['total'], $page, $limit, ); } #[OA\Get( path: '/api/v1/appointments/user', summary: 'List appointments for the authenticated user', security: [['bearerAuth' => []]], parameters: [ new OA\Parameter( name: 'status', in: 'query', required: false, description: 'Filter by appointment status', schema: new OA\Schema(type: 'string', example: 'pending') ), ], responses: [ new OA\Response( response: 200, description: 'Appointment list returned', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property( property: 'data', type: 'array', items: new OA\Items(type: 'object', description: 'Appointment object') ), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response(response: 401, description: 'Unauthenticated'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointments/user', methods: ['GET'])] public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse { $status = $request->query->get('status'); $appointments = $this->appointmentRepo->findByUser($user, $status); return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]); } private function canView(Appointment $a, User $user): bool { return $this->accessChecker->canView($a, $user); } private function canManage(Appointment $a, User $user): bool { return $this->accessChecker->canManage($a, $user); } /** * محلِ نوبت‌دهی این درخواست. بدون clinic_uuid یعنی مطب شخصی پزشک — نه «هر محلی * که پیدا شد»: با چند برنامهٔ هم‌زمان، حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای * اشتباه. */ private function bookingClinic(Doctor $doctor, ?string $clinicUuid): ?Clinic { return $this->bookingContext->resolve($doctor, $clinicUuid); } /** * آیا این درخواستِ اسلات از پنل مدیریت است (پزشک/منشی/ادمینِ دارای دسترسی)؟ * اندپوینت‌های اسلات عمومی‌اند؛ فقط با management=1 + کاربرِ احرازشده و مجاز، * توگلِ نوبت‌دهی آنلاین دور زده می‌شود. در غیر این‌صورت مثل رزرو عمومی رفتار می‌شود * (fail-safe عمومی) تا اسلاتِ خاموش به بازدیدکنندهٔ سایت نشت نکند. */ private function isManagementContext(Request $request, Doctor $doctor, ?Clinic $clinic): bool { if ($request->query->get('management') !== '1') { return false; } $user = $this->getUser(); if (!$user instanceof User) { return false; } return $this->accessChecker->canManageContext($user, $doctor, $clinic); } private function assertServicesMatchContext(array $serviceUuids, Doctor $doctor, ?Clinic $clinic): ?JsonResponse { [$type, $id] = $clinic !== null ? ['clinic', $clinic->getId()] : ['doctor', $doctor->getId()]; foreach ($serviceUuids as $uuid) { $section = $this->itemRepo->findByUuid($uuid)?->getSection(); if ($section === null || $section->getEntityType() !== $type || $section->getEntityId() !== $id) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids'); } } return null; } /** * تاریخ Y-m-d که واقعاً روی تقویم وجود دارد. regex تنها کافی نیست: «2026-13-99» * الگو را پاس می‌کند ولی روزی نیست. */ private function isCalendarDate(string $date): bool { $parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date); return $parsed !== false && $parsed->format('Y-m-d') === $date; } /** @return array> */ private function bookableServices(Doctor $doctor, ?Clinic $clinic): array { [$type, $id] = $clinic !== null ? ['clinic', $clinic->getId()] : ['doctor', $doctor->getId()]; return array_map(function (\App\ClinicService\Entity\ServiceItem $i): array { $section = $i->getSection(); return [ 'uuid' => $i->getUuid(), 'name' => $i->getName(), 'duration_minutes' => $i->getDurationMinutes(), 'price_rials' => $i->getPriceRials(), 'service_section' => ['uuid' => $section->getUuid(), 'name' => $section->getName()], ]; }, $this->itemRepo->findBookableByEntity($type, $id)); } /** * شیفت‌های فعال هفته به‌صورت تخت، با نام انگلیسی روز — آمادهٔ نگاشت به * openingHoursSpecification در schema.org. کلیدهای برنامه 0..6 هستند و 0 شنبه است. * * فقط شیفت‌هایی برمی‌گردند که آدرسشان در $allowedAddressIds باشد. * * @param array $allowedAddressIds * * @return array */ private function openingHours(WeeklySchedule $schedule, array $allowedAddressIds): array { $hours = []; foreach ($schedule->getDaySchedule() as $dayIndex => $day) { $dayName = WeeklySchedule::DAYS[(int) $dayIndex] ?? null; if ($dayName === null) { continue; } foreach (($day['sessions'] ?? []) as $session) { if (!($session['active'] ?? false)) { continue; } // شیفتی که آدرس ندارد یا به آدرسی خارج از این محیط اشاره می‌کند، // قابل رزرو نیست و نباید ساعت کاری تولید کند. $locationId = (int) ($session['location_id'] ?? 0); if ($locationId === 0 || !isset($allowedAddressIds[$locationId])) { continue; } $opens = $session['start_time'] ?? null; $closes = $session['end_time'] ?? null; if ($opens === null || $closes === null) { continue; } $hours[] = [ 'day' => ucfirst($dayName), 'day_index' => (int) $dayIndex, 'location_id' => $locationId, 'opens' => $opens, 'closes' => $closes, ]; } } return $hours; } #[OA\Patch( path: '/api/v1/appointment/{uuid}/status', summary: 'Update the status of an appointment', security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['status'], properties: [ new OA\Property(property: 'status', type: 'string', example: 'confirmed'), new OA\Property(property: 'version', type: 'integer', description: 'Optimistic lock version'), ] ) ), parameters: [ new OA\Parameter( name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string', format: 'uuid') ), ], responses: [ new OA\Response( response: 200, description: 'Appointment status updated', content: new OA\JsonContent( properties: [ new OA\Property(property: 'success', type: 'boolean', example: true), new OA\Property(property: 'data', type: 'object', description: 'Updated appointment object'), new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), ] ) ), new OA\Response(response: 401, description: 'Unauthenticated'), new OA\Response(response: 403, description: 'Access denied'), new OA\Response(response: 404, description: 'Appointment not found'), new OA\Response(response: 409, description: 'Optimistic lock conflict'), new OA\Response(response: 422, description: 'Invalid status transition'), ] )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])] public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $appointment = $this->appointmentRepo->findByUuid($uuid); if ($appointment === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); } $data = json_decode($request->getContent(), true) ?? []; $newStatus = trim($data['status'] ?? ''); $version = (int) ($data['version'] ?? $appointment->getVersion()); // لغو مجوز جداگانه دارد: منشی به‌صورت پیش‌فرض اجازهٔ لغو ندارد ولی وضعیت‌های // دیگر را تغییر می‌دهد. $action = in_array($newStatus, self::CANCEL_STATUSES, true) ? AppointmentAccessChecker::ACTION_CANCEL : AppointmentAccessChecker::ACTION_UPDATE_STATUS; if (!$this->accessChecker->can($appointment, $user, $action)) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } if (!$appointment->canTransitionTo($newStatus)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf( 'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus ), 422); } $appointment->transitionTo($newStatus); try { $this->appointmentRepo->saveWithLock($appointment, $version); } catch (OptimisticLockException) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409); } if ($newStatus === Appointment::STATUS_CONFIRMED) { $this->appointmentConfirmation->onConfirmed($appointment); } if (in_array($newStatus, self::CANCEL_STATUSES, true)) { $reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : ''; $this->recordCancellation($appointment, $newStatus, $reason !== '' ? $reason : null, $user); } return $this->success(['data' => $appointment->toArray()]); } /** * قطعی‌کردن نوبت به‌همراه پرداخت — «ثبت‌شده» → «قطعی». * * یک عملِ اتمیک: انتقال وضعیت، ساخت/یافتنِ پروندهٔ همان محیط با سرویس‌های نوبت، * و ثبت پرداخت‌های کامل یا جزئی روی همان مراجعه. اگر هر مرحله شکست بخورد هیچ‌کدام * ثبت نمی‌شوند. */ #[OA\Post( path: '/api/v1/appointment/{uuid}/confirm', summary: 'Confirm an appointment and register its payments on the patient case file', security: [['bearerAuth' => []]], responses: [ new OA\Response(response: 200, description: 'Appointment confirmed'), new OA\Response(response: 403, description: 'Access denied, or payments sent without the patient_records feature'), new OA\Response(response: 404, description: 'Appointment not found'), new OA\Response(response: 409, description: 'Version conflict'), new OA\Response(response: 422, description: 'Invalid transition or payment'), ] )] #[Route('/api/v1/appointment/{uuid}/confirm', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function confirm(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $appointment = $this->appointmentRepo->findByUuid($uuid); if ($appointment === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); } if (!$this->accessChecker->can($appointment, $user, AppointmentAccessChecker::ACTION_UPDATE_STATUS)) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } $data = json_decode($request->getContent(), true) ?? []; $version = (int) ($data['version'] ?? $appointment->getVersion()); if (!$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf( 'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), Appointment::STATUS_CONFIRMED ), 422); } // پرداختِ چندروشی (split): هر ردیف یک روش + مبلغ، و به‌اختیار جزئیاتِ روش // (uuid کارت‌خوان/حساب بانکیِ ثبت‌شده + شناسه تراکنش). $payments = []; foreach ((array) ($data['payments'] ?? []) as $row) { $method = trim((string) ($row['method'] ?? '')); $amount = (int) ($row['amount_rials'] ?? 0); if (!in_array($method, \App\Patient\Entity\SessionPayment::METHODS, true)) { return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'method'); } if ($amount <= 0) { return $this->error(ErrorCodes::ERR_SESSION_PAYMENT_INVALID, ErrorCodes::message(ErrorCodes::ERR_SESSION_PAYMENT_INVALID), 422, 'amount_rials'); } $methodUuid = trim((string) ($row['payment_method_uuid'] ?? '')); $reference = trim((string) ($row['reference'] ?? '')); $payments[] = [ 'method' => $method, 'amount_rials' => $amount, 'payment_method_uuid' => $methodUuid !== '' ? mb_substr($methodUuid, 0, 36) : null, 'reference' => $reference !== '' ? mb_substr($reference, 0, 255) : null, ]; } // انتخاب بیمه سرِ پذیرش: قبل از ساخت مراجعه روی نوبت می‌نشیند تا سهم‌ها با // همان بیمه محاسبه شوند. $this->appointmentInsurance->apply($appointment, $data); try { $session = $this->appointmentConfirmation->confirmWithPayments($appointment, $version, $payments, $user); } catch (OptimisticLockException) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409); } return $this->success([ 'appointment' => $appointment->toArray(), 'session' => $session === null ? null : [ 'uuid' => $session->getUuid(), 'visit_price_rials' => $session->getVisitPriceRials(), 'services_total_rials' => $session->getServicesTotalRials(), 'final_price_rials' => $session->getFinalPriceRials(), 'discount_rials' => $session->getDiscountRials(), 'paid_total_rials' => $session->getPaidTotalRials(), 'remaining_rials' => $session->getRemainingRials(), 'is_paid' => $session->getRemainingRials() === 0, // تفکیک بیمه — مودالِ قطعی‌کردن همان مبلغی را نشان می‌دهد که ثبت شده. 'insurance_service_category' => $session->getInsuranceServiceCategory()?->value, 'insurance_base_id' => $session->getInsuranceBaseId(), 'insurance_supplementary_id' => $session->getInsuranceSupplementaryId(), 'gross_total_rials' => $session->getGrossTotalRials(), 'base_insurance_rials' => $session->getBaseInsuranceRials(), 'supplementary_insurance_rials' => $session->getSupplementaryInsuranceRials(), 'patient_share_rials' => $session->getPatientShareRials(), ], ]); } /** * General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). * All fields optional; only what is present in the body changes. Slot moves * go through rescheduleTo so active_slot_key stays consistent. Optimistic * lock via `version` like the status endpoint. */ #[Route('/api/v1/appointment/{uuid}', methods: ['PATCH'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $appointment = $this->appointmentRepo->findByUuid($uuid); if ($appointment === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); } if (!$this->canManage($appointment, $user)) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } $data = json_decode($request->getContent(), true) ?? []; $version = (int) ($data['version'] ?? $appointment->getVersion()); // status درون‌خطی نباید گیت لغو را دور بزند. if (in_array(trim((string) ($data['status'] ?? '')), self::CANCEL_STATUSES, true) && !$this->accessChecker->canCancel($appointment, $user)) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } // ── حالت نوبت‌دهی سرویسی: مدت، داده است نه ورودی ────────────────────────── // سرویس‌ها باید پیش از بلوک زمان حل شوند، چون مدتِ مجاز به آن‌ها وابسته است. // در حالت اسلاتی هیچ‌کدام از این خطوط اجرا نمی‌شود و رفتار بیت‌به‌بیت همان می‌ماند. $serviceMode = $this->serviceCalculator->isServiceMode($appointment->getDoctor(), $appointment->getClinic()); $hasServiceSet = array_key_exists('service_item_uuids', $data); $duration = null; if ($serviceMode) { $requestedUuids = $hasServiceSet ? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids']))) : $appointment->currentServiceUuids(); if ($requestedUuids !== []) { $duration = $this->serviceCalculator->calculate( $appointment->getDoctor(), $appointment->getClinic(), $requestedUuids, (array) ($data['durations'] ?? []), // سرویسِ غیرفعالِ نوبتِ موجود نباید نوبت را برای همیشه قفل کند؛ ولی // افزودن سرویس غیرفعالِ تازه رد می‌شود. allowInactive: !$hasServiceSet, ); } } // Slot move / reserve toggle — both times together, or neither. $hasStart = array_key_exists('slot_start', $data); $hasEnd = array_key_exists('slot_end', $data); if ($hasStart !== $hasEnd) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'slot_start و slot_end باید با هم ارسال شوند', 422, 'slot_start'); } if ($hasStart || array_key_exists('is_reserve', $data)) { $newStart = $hasStart ? (int) $data['slot_start'] : $appointment->getSlotStart(); $newEnd = $hasStart ? (int) $data['slot_end'] : $appointment->getSlotEnd(); if ($newEnd < $newStart) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'ساعت پایان قبل از شروع است', 422, 'slot_end'); } $isReserve = array_key_exists('is_reserve', $data) ? (bool) $data['is_reserve'] : null; $movingToLiveSlot = ($isReserve ?? $appointment->isReserve()) === false; // نوبتِ زمان‌دارِ سرویسی نمی‌تواند مدت دلخواه بگیرد. نوبت رزرو معاف است: // slot_start == slot_end دارد و بازه‌ای اشغال نمی‌کند. if ($movingToLiveSlot && $duration !== null && $newEnd !== $duration->endFor($newStart)) { return $this->error( ErrorCodes::ERR_APPOINTMENT_003, sprintf('مدت این نوبت باید %d دقیقه باشد', $duration->totalMinutes), 422, 'slot_end', ); } if ($movingToLiveSlot && ($newStart !== $appointment->getSlotStart() || $newEnd !== $appointment->getSlotEnd()) && $this->appointmentRepo->isSlotTaken($appointment->getDoctor(), $newStart, $newEnd, $appointment->getId())) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start'); } // rescheduleTo() خودش refreshActiveSlotKey() را صدا می‌زند، پس تبدیل رزرو به // نوبت زمان‌دار (is_reserve: false) کلید یکتایی را بازتولید می‌کند. $appointment->rescheduleTo($newStart, $newEnd, $isReserve); } if ($duration !== null) { if ($hasServiceSet) { $appointment->replaceServiceItems($duration->serviceItems); } $appointment->setServiceDuration($duration->totalMinutes, $duration->bufferMinutes); } // Workflow relations — empty string clears, uuid assigns, unknown → 422. // `service_item_uuid` تکی وقتی نادیده گرفته می‌شود که فهرست کامل آمده باشد، // وگرنه دو منبع برای یک چیز به نوبتِ ناسازگار می‌رسد. foreach ([ 'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'], 'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'], 'staff_uuid' => [$this->staffRepo, 'setStaff', 'پرسنل'], ] as $key => [$repo, $setter, $label]) { if ($key === 'service_item_uuid' && $hasServiceSet) { continue; } if (!array_key_exists($key, $data)) { continue; } $value = trim((string) ($data[$key] ?? '')); if ($value === '') { $appointment->$setter(null); continue; } $entity = $repo->findByUuid($value); if ($entity === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, $label . ' یافت نشد', 422, $key); } $appointment->$setter($entity); } if (array_key_exists('deposit_required', $data)) { $appointment->setDepositRequired((bool) $data['deposit_required']); } if (array_key_exists('deposit_amount_rials', $data)) { $appointment->setDepositAmountRials($data['deposit_amount_rials'] !== null ? (int) $data['deposit_amount_rials'] : null); } if (array_key_exists('note', $data)) { $appointment->setNote($data['note'] !== null ? trim((string) $data['note']) : null); } $this->appointmentInsurance->apply($appointment, $data); // جایگزینی نوبت — swap the person occupying the slot. if (array_key_exists('patient_name', $data)) { $appointment->setPatientName($data['patient_name'] !== null ? trim((string) $data['patient_name']) : null); } if (array_key_exists('patient_mobile', $data)) { $appointment->setPatientMobile($data['patient_mobile'] !== null ? trim((string) $data['patient_mobile']) : null); } // Optional status transition, same rules as the dedicated endpoint. $newStatus = trim((string) ($data['status'] ?? '')); $cancelledTo = null; if ($newStatus !== '' && $newStatus !== $appointment->getStatus()) { if (!$appointment->canTransitionTo($newStatus)) { return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf( 'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus ), 422); } $appointment->transitionTo($newStatus); if ($newStatus === Appointment::STATUS_CONFIRMED) { $this->appointmentConfirmation->onConfirmed($appointment); } if (in_array($newStatus, self::CANCEL_STATUSES, true)) { $cancelledTo = $newStatus; } } try { $this->appointmentRepo->saveWithLock($appointment, $version); } catch (OptimisticLockException) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409); } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) { // race backstop: someone grabbed the slot between the pre-check and the flush return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start'); } if ($cancelledTo !== null) { $reason = isset($data['cancel_reason']) ? trim((string) $data['cancel_reason']) : ''; $this->recordCancellation($appointment, $cancelledTo, $reason !== '' ? $reason : null, $user); } return $this->success(['data' => $appointment->toArray()]); } /** * جابه‌جایی سرویس‌آگاه — فقط حالت نوبت‌دهی سرویسی. * * کلاینت مدت نمی‌فرستد: `start` می‌دهد و سرور مدت را از سرویس‌های نوبت (یا فهرست * صریحِ درخواست) حساب می‌کند. این تفاوت با `PATCH` است که کلاینت باید `slot_end` * درست را از قبل بداند. * * POST /api/v1/appointment/{uuid}/service-reschedule */ #[Route('/api/v1/appointment/{uuid}/service-reschedule', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function serviceReschedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { $appointment = $this->appointmentRepo->findByUuid($uuid); if ($appointment === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); } if (!$this->canManage($appointment, $user)) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } $data = json_decode($request->getContent(), true) ?? []; $start = (int) ($data['start'] ?? 0); if ($start <= 0) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'زمان شروع الزامی است', 422, 'start'); } $serviceUuids = array_key_exists('service_item_uuids', $data) ? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids']))) : null; try { $duration = $this->rescheduleService->reschedule( $appointment, $start, $serviceUuids, (array) ($data['durations'] ?? []), $this->accessChecker->canManageContext($user, $appointment->getDoctor(), $appointment->getClinic()), array_key_exists('version', $data) ? (int) $data['version'] : null, ); } catch (OptimisticLockException) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409); } catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) { return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'start'); } return $this->success([ 'uuid' => $appointment->getUuid(), 'slot_start' => $appointment->getSlotStart(), 'slot_end' => $appointment->getSlotEnd(), 'total_duration_minutes' => $duration->totalMinutes, 'buffer_minutes' => $duration->bufferMinutes, 'warnings' => $duration->warnings, ]); } // ── Timeline: رویدادهای یک نوبت ─────────────────────────────────────────── #[Route('/api/v1/appointment/{uuid}/events', methods: ['GET'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function events(string $uuid, #[CurrentUser] User $user): JsonResponse { $appointment = $this->appointmentRepo->findByUuid($uuid); if ($appointment === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); } if (!$this->canView($appointment, $user)) { return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403); } return $this->success($this->eventRepo->findByAppointmentUuid($uuid)); } }